1package content
2
3import (
4 "context"
5 neturl "net/url"
6 "strings"
7 "sync"
8 "time"
9
10 "github.com/bnema/dumber/internal/application/port"
11 "github.com/bnema/dumber/internal/domain/entity"
12 "github.com/bnema/dumber/internal/logging"
13 "github.com/bnema/puregotk/v4/glib"
14)
15
16// oauthFlowPatterns are URL patterns that indicate an OAuth flow is in progress.
17// These appear in authorization URLs and callback redirects.
18var oauthFlowPatterns = []string{
19 // OAuth flow indicators
20 "oauth",
21 "authorize",
22 "authorization",
23 "auth/",
24 "/auth",
25 "login",
26 "signin",
27 "sign-in",
28 // OpenID Connect
29 "oidc",
30 "openid",
31 // Callback/redirect endpoints
32 "callback",
33 "redirect",
34 "/cb",
35}
36
37// oauthRequestPatterns are query parameters found in OAuth authorization requests.
38var oauthRequestPatterns = []string{
39 "response_type=",
40 "client_id=",
41 "redirect_uri=",
42 "scope=",
43 "nonce=",
44}
45
46// IsOAuthURL checks if the URL is related to an OAuth flow.
47// This includes authorization endpoints, login pages, and callback URLs.
48//
49// The matching is intentionally broad: both oauthFlowPatterns (path-based terms like
50// "login", "authorize", "callback") and oauthRequestPatterns (query parameters like
51// "client_id=" and "scope=") use simple substring matching without anchoring.
52// This maximizes recall — we prefer false positives over missed detections — so that
53// edge-case provider URLs and non-standard redirect paths are still caught. The
54// trade-off is reduced precision: some unrelated URLs that happen to contain these
55// substrings will be classified as OAuth URLs. Callers that need higher confidence
56// should combine this with IsOAuthCallback, which checks for concrete response params.
57func IsOAuthURL(url string) bool {
58 if url == "" {
59 return false
60 }
61 lower := strings.ToLower(url)
62
63 // Check for OAuth flow patterns in URL path
64 for _, pattern := range oauthFlowPatterns {
65 if strings.Contains(lower, pattern) {
66 return true
67 }
68 }
69
70 // Check for OAuth request parameters
71 for _, pattern := range oauthRequestPatterns {
72 if strings.Contains(lower, pattern) {
73 return true
74 }
75 }
76
77 return false
78}
79
80// IsOAuthCallback checks if the URL is an OAuth callback with response parameters.
81// This indicates the OAuth flow has completed (successfully or with error).
82func IsOAuthCallback(url string) bool {
83 if url == "" {
84 return false
85 }
86 u, err := neturl.Parse(url)
87 if err != nil {
88 return false
89 }
90 if hasOAuthCallbackParam(u.Query()) {
91 return true
92 }
93 if u.Fragment != "" {
94 if fragVals, err := neturl.ParseQuery(u.Fragment); err == nil {
95 return hasOAuthCallbackParam(fragVals)
96 }
97 }
98 return false
99}
100
101// ShouldAutoClose determines if a popup at this URL should auto-close.
102// Returns true for OAuth callbacks that indicate flow completion.
103//
104// This is comprehensive detection that handles:
105// - Success: code=, access_token=, id_token=
106// - Errors: error=, error_description=
107// - Various OAuth providers (Google, GitHub, Auth0, etc.)
108func ShouldAutoClose(url string) bool {
109 return IsOAuthCallback(url)
110}
111
112// shouldForceCloseOnSafetyTimeout determines if a popup should be force-closed
113// when the OAuth safety timeout is reached.
114//
115// Safety-timeout force close is disabled for stability. OAuth popups close on
116// callback detection or manual user action.
117func shouldForceCloseOnSafetyTimeout() bool {
118 return false
119}
120
121// IsOAuthSuccess checks if the callback indicates successful authentication.
122// It parses query and fragment parameters (like IsOAuthCallback) to avoid
123// false positives from substring matching.
124func IsOAuthSuccess(rawURL string) bool {
125 if rawURL == "" {
126 return false
127 }
128 u, err := neturl.Parse(rawURL)
129 if err != nil {
130 return false
131 }
132 if hasOAuthSuccessParam(u.Query()) {
133 return true
134 }
135 if u.Fragment != "" {
136 if fragVals, err := neturl.ParseQuery(u.Fragment); err == nil {
137 return hasOAuthSuccessParam(fragVals)
138 }
139 }
140 return false
141}
142
143// hasOAuthSuccessParam returns true if params contain a success token
144// (code, access_token, id_token) and no error key.
145func hasOAuthSuccessParam(params neturl.Values) bool {
146 if params.Has("error") {
147 return false
148 }
149 return params.Has("code") || params.Has("access_token") || params.Has("id_token")
150}
151
152// IsOAuthError checks if the callback indicates an authentication error.
153func IsOAuthError(url string) bool {
154 if url == "" {
155 return false
156 }
157 u, err := neturl.Parse(url)
158 if err != nil {
159 return false
160 }
161 if _, ok := u.Query()["error"]; ok {
162 return true
163 }
164 if u.Fragment != "" {
165 if fragVals, err := neturl.ParseQuery(u.Fragment); err == nil {
166 if _, ok := fragVals["error"]; ok {
167 return true
168 }
169 }
170 }
171 return false
172}
173
174func hasOAuthCallbackParam(v neturl.Values) bool {
175 for _, k := range []string{"code", "access_token", "id_token", "token_type", "refresh_token", "error", "error_description", "error_uri"} {
176 if _, ok := v[k]; ok {
177 return true
178 }
179 }
180 return false
181}
182
183func composeOnClose(existing, next func()) func() {
184 if existing == nil {
185 return next
186 }
187 if next == nil {
188 return existing
189 }
190 return func() {
191 existing()
192 next()
193 }
194}
195
196func composeOnURIChanged(existing, next func(string)) func(string) {
197 if existing == nil {
198 return next
199 }
200 if next == nil {
201 return existing
202 }
203 return func(uri string) {
204 existing(uri)
205 next(uri)
206 }
207}
208
209func composeOnLoadChanged(
210 existing func(port.LoadEvent),
211 next func(port.LoadEvent),
212) func(port.LoadEvent) {
213 if existing == nil {
214 return next
215 }
216 if next == nil {
217 return existing
218 }
219 return func(event port.LoadEvent) {
220 existing(event)
221 next(event)
222 }
223}
224
225// setupOAuthAutoClose monitors the popup for OAuth callback URLs and auto-closes.
226// It uses URL pattern detection for standard OAuth callbacks (code=, access_token=, etc.)
227// For providers using postMessage (like Google Sign-In), we rely on the provider calling
228// window.close() which triggers WebKit's close signal.
229// A long safety timeout (30s) catches popups that get stuck.
230func (c *Coordinator) setupOAuthAutoClose(
231 ctx context.Context,
232 paneID entity.PaneID,
233 popupID port.WebViewID,
234 wv port.WebView,
235) {
236 log := logging.FromContext(ctx)
237
238 // Use the optional OAuthCallbackCapable capability instead of a webkit-specific assertion.
239 oauthWV, ok := wv.(port.OAuthCallbackCapable)
240 if !ok {
241 log.Debug().Str("pane_id", string(paneID)).Msg("oauth auto-close: webview does not support oauth callbacks")
242 return
243 }
244
245 // Safety timeout - only triggers if popup gets stuck (provider should close via window.close)
246 var safetyTimer *time.Timer
247 var safetyTimerMu sync.Mutex
248 var cancelSafetyTimerOnce sync.Once
249 var requestCloseOnce sync.Once
250 const oauthSafetyTimeout = 30 * time.Second
251 const oauthCloseDelay = 500 * time.Millisecond
252
253 startSafetyTimer := func() {
254 safetyTimerMu.Lock()
255 defer safetyTimerMu.Unlock()
256 if safetyTimer != nil {
257 safetyTimer.Stop()
258 }
259 safetyTimer = time.AfterFunc(oauthSafetyTimeout, func() {
260 cb := glib.SourceFunc(func(_ uintptr) bool {
261 if wv != nil && !wv.IsDestroyed() {
262 uri := wv.URI()
263 if shouldForceCloseOnSafetyTimeout() {
264 log.Warn().Str("pane", string(paneID)).Msg("oauth safety timeout, closing stuck popup")
265 oauthWV.Close()
266 return false
267 }
268 log.Debug().
269 Str("pane", string(paneID)).
270 Str("uri", logging.TruncateURL(uri, logURLMaxLen)).
271 Msg("oauth safety timeout reached during active auth flow, skipping forced close")
272 }
273 return false
274 })
275 glib.IdleAdd(&cb, 0)
276 })
277 }
278
279 cancelSafetyTimer := func() {
280 cancelSafetyTimerOnce.Do(func() {
281 safetyTimerMu.Lock()
282 defer safetyTimerMu.Unlock()
283 if safetyTimer != nil {
284 safetyTimer.Stop()
285 safetyTimer = nil
286 }
287 })
288 }
289
290 requestOAuthClose := func(uri string, reason string) {
291 c.capturePopupOAuthState(popupID, uri)
292 cancelSafetyTimer()
293 log.Info().
294 Str("pane", string(paneID)).
295 Str("reason", reason).
296 Msg("oauth callback detected, closing")
297 requestCloseOnce.Do(func() {
298 time.AfterFunc(oauthCloseDelay, func() {
299 cb := glib.SourceFunc(func(_ uintptr) bool {
300 if wv != nil && !wv.IsDestroyed() {
301 oauthWV.Close()
302 }
303 return false
304 })
305 glib.IdleAdd(&cb, 0)
306 })
307 })
308 }
309
310 // Start safety timer immediately.
311 startSafetyTimer()
312
313 // Register navigation callback to check for OAuth callbacks on URI changes and committed loads.
314 oauthWV.AddNavigationCallback(func(uri string) {
315 if ShouldAutoClose(uri) {
316 requestOAuthClose(uri, "navigation")
317 }
318 })
319
320 // Cancel safety timer on any close path.
321 oauthWV.AddCloseCallback(func() {
322 cancelSafetyTimer()
323 })
324}
325
326func (c *Coordinator) trackOAuthPopup(popupID port.WebViewID, parentPaneID entity.PaneID) {
327 c.popupMu.Lock()
328 defer c.popupMu.Unlock()
329 if c.popupOAuth == nil {
330 c.popupOAuth = make(map[port.WebViewID]*popupOAuthState)
331 }
332 c.popupOAuth[popupID] = &popupOAuthState{
333 ParentPaneID: parentPaneID,
334 }
335}
336
337func (c *Coordinator) capturePopupOAuthState(popupID port.WebViewID, uri string) {
338 c.popupMu.Lock()
339 defer c.popupMu.Unlock()
340
341 state, ok := c.popupOAuth[popupID]
342 if !ok {
343 return
344 }
345
346 state.Seen = true
347 state.CallbackURI = uri
348 state.Success = IsOAuthSuccess(uri)
349 state.Error = IsOAuthError(uri)
350}
351
352func (c *Coordinator) handlePopupOAuthClose(ctx context.Context, popupID port.WebViewID) {
353 log := logging.FromContext(ctx)
354
355 c.popupMu.Lock()
356 state, ok := c.popupOAuth[popupID]
357 if ok {
358 delete(c.popupOAuth, popupID)
359 }
360 c.popupMu.Unlock()
361
362 if !ok || state == nil || !state.Seen {
363 return
364 }
365
366 log.Debug().
367 Uint64("popup_id", uint64(popupID)).
368 Str("parent_pane_id", string(state.ParentPaneID)).
369 Bool("oauth_success", state.Success).
370 Bool("oauth_error", state.Error).
371 Msg("popup oauth result captured on close")
372
373 if !state.Success || state.ParentPaneID == "" {
374 return
375 }
376
377 c.scheduleParentPaneRefresh(ctx, state.ParentPaneID, popupID)
378}
379
380func (c *Coordinator) scheduleParentPaneRefresh(
381 ctx context.Context,
382 parentPaneID entity.PaneID,
383 popupID port.WebViewID,
384) {
385 c.popupMu.Lock()
386 if c.popupRefresh == nil {
387 c.popupRefresh = make(map[entity.PaneID]*time.Timer)
388 }
389 if existing := c.popupRefresh[parentPaneID]; existing != nil {
390 existing.Stop()
391 }
392 c.popupRefresh[parentPaneID] = time.AfterFunc(oauthParentRefreshDebounce, func() {
393 c.popupMu.Lock()
394 delete(c.popupRefresh, parentPaneID)
395 c.popupMu.Unlock()
396 cb := glib.SourceFunc(func(_ uintptr) bool {
397 c.refreshPaneAfterOAuth(ctx, parentPaneID, popupID)
398 return false
399 })
400 glib.IdleAdd(&cb, 0)
401 })
402 c.popupMu.Unlock()
403}
404
405func (c *Coordinator) refreshPaneAfterOAuth(
406 ctx context.Context,
407 parentPaneID entity.PaneID,
408 popupID port.WebViewID,
409) {
410 log := logging.FromContext(ctx)
411 wv := c.getWebViewLocked(parentPaneID)
412 if wv == nil || wv.IsDestroyed() {
413 log.Debug().
414 Str("parent_pane_id", string(parentPaneID)).
415 Uint64("popup_id", uint64(popupID)).
416 Msg("skipping parent pane refresh after oauth close: parent webview unavailable")
417 return
418 }
419
420 if err := wv.Reload(ctx); err != nil {
421 log.Warn().
422 Err(err).
423 Str("parent_pane_id", string(parentPaneID)).
424 Uint64("popup_id", uint64(popupID)).
425 Msg("failed parent pane refresh after oauth popup close")
426 return
427 }
428
429 log.Info().
430 Str("parent_pane_id", string(parentPaneID)).
431 Uint64("popup_id", uint64(popupID)).
432 Msg("refreshed parent pane after oauth popup success")
433}