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(existing, next func(port.LoadEvent)) func(port.LoadEvent) {
210 if existing == nil {
211 return next
212 }
213 if next == nil {
214 return existing
215 }
216 return func(event port.LoadEvent) {
217 existing(event)
218 next(event)
219 }
220}
221
222// setupOAuthAutoClose monitors the popup for OAuth callback URLs and auto-closes.
223// It uses URL pattern detection for standard OAuth callbacks (code=, access_token=, etc.)
224// and a long safety timeout for stuck popups.
225func (c *Coordinator) setupOAuthAutoClose(
226 ctx context.Context,
227 paneID entity.PaneID,
228 popupID port.WebViewID,
229 wv port.WebView,
230) {
231 log := logging.FromContext(ctx)
232
233 // Use the optional OAuthCallbackCapable capability instead of a webkit-specific assertion.
234 oauthWV, ok := wv.(port.OAuthCallbackCapable)
235 if !ok {
236 log.Debug().Str("pane_id", string(paneID)).Msg("oauth auto-close: webview does not support oauth callbacks")
237 return
238 }
239
240 // Safety timeout - only triggers if popup gets stuck (provider should close via window.close)
241 var safetyTimer *time.Timer
242 var safetyTimerMu sync.Mutex
243 var cancelSafetyTimerOnce sync.Once
244 var requestCloseOnce sync.Once
245 const oauthSafetyTimeout = 30 * time.Second
246 const oauthCloseDelay = 500 * time.Millisecond
247
248 startSafetyTimer := func() {
249 safetyTimerMu.Lock()
250 defer safetyTimerMu.Unlock()
251 if safetyTimer != nil {
252 safetyTimer.Stop()
253 }
254 safetyTimer = time.AfterFunc(oauthSafetyTimeout, func() {
255 cb := glib.SourceFunc(func(_ uintptr) bool {
256 if wv != nil && !wv.IsDestroyed() {
257 uri := wv.URI()
258 if shouldForceCloseOnSafetyTimeout() {
259 log.Warn().Str("pane", string(paneID)).Msg("oauth safety timeout, closing stuck popup")
260 oauthWV.Close()
261 return false
262 }
263 log.Debug().
264 Str("pane", string(paneID)).
265 Str("uri", logging.TruncateURL(uri, logURLMaxLen)).
266 Msg("oauth safety timeout reached during active auth flow, skipping forced close")
267 }
268 return false
269 })
270 glib.IdleAdd(&cb, 0)
271 })
272 }
273
274 cancelSafetyTimer := func() {
275 cancelSafetyTimerOnce.Do(func() {
276 safetyTimerMu.Lock()
277 defer safetyTimerMu.Unlock()
278 if safetyTimer != nil {
279 safetyTimer.Stop()
280 safetyTimer = nil
281 }
282 })
283 }
284
285 requestOAuthClose := func(reason string) {
286 cancelSafetyTimer()
287 log.Info().
288 Str("pane", string(paneID)).
289 Str("reason", reason).
290 Msg("oauth callback detected, closing")
291 requestCloseOnce.Do(func() {
292 time.AfterFunc(oauthCloseDelay, func() {
293 cb := glib.SourceFunc(func(_ uintptr) bool {
294 if wv != nil && !wv.IsDestroyed() {
295 oauthWV.Close()
296 }
297 return false
298 })
299 glib.IdleAdd(&cb, 0)
300 })
301 })
302 }
303
304 // Start safety timer immediately.
305 startSafetyTimer()
306 log.Debug().
307 Str("pane", string(paneID)).
308 Uint64("popup_id", uint64(popupID)).
309 Msg("oauth auto-close configured")
310
311 // Register navigation callback to check for OAuth callbacks on URI changes and committed loads.
312 oauthWV.AddNavigationCallback(func(uri string) {
313 if !ShouldAutoClose(uri) {
314 return
315 }
316 c.capturePopupOAuthState(popupID, uri)
317 requestOAuthClose("navigation")
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, parentURIAtOpen string) {
327 c.ensurePopupManager().trackOAuthPopup(popupID, parentPaneID, parentURIAtOpen)
328}
329
330func (c *Coordinator) capturePopupOAuthState(popupID port.WebViewID, uri string) {
331 c.ensurePopupManager().capturePopupOAuthState(popupID, uri)
332}
333
334func (c *Coordinator) capturePopupOAuthMessage(popupID port.WebViewID) {
335 c.ensurePopupManager().capturePopupOAuthMessage(popupID)
336}
337
338func (c *Coordinator) handlePopupOAuthClose(ctx context.Context, popupID port.WebViewID) {
339 log := logging.FromContext(ctx)
340
341 state, ok := c.ensurePopupManager().takePopupOAuthState(popupID)
342
343 if !ok || state == nil || !state.Seen {
344 return
345 }
346
347 log.Debug().
348 Uint64("popup_id", uint64(popupID)).
349 Str("parent_pane_id", string(state.ParentPaneID)).
350 Bool("oauth_success", state.Success).
351 Bool("oauth_error", state.Error).
352 Msg("popup oauth result captured on close")
353
354 if state.ParentPaneID == "" {
355 return
356 }
357
358 if !state.Success {
359 return
360 }
361
362 c.scheduleParentPaneOAuthResume(ctx, state.ParentPaneID, popupID, state.ParentURIAtOpen, state.CallbackURI)
363}
364
365// oauthParentResumeGraceRetries gives the parent pane up to
366// oauthParentResumeGraceRetries * oauthParentRefreshDebounce (~1s with the
367// current 200ms debounce) to finish its own in-flight same-site OAuth
368// navigation before scheduleParentPaneOAuthResume falls back to Reload(). Five
369// retries keeps the UI responsive without racing the natural callback handoff.
370const oauthParentResumeGraceRetries = 5
371
372func (c *Coordinator) scheduleParentPaneOAuthResume(
373 ctx context.Context,
374 parentPaneID entity.PaneID,
375 popupID port.WebViewID,
376 parentURIAtOpen, callbackURI string,
377) {
378 c.scheduleParentPaneOAuthResumeAttempt(
379 ctx,
380 parentPaneID,
381 popupID,
382 parentURIAtOpen,
383 callbackURI,
384 oauthParentResumeGraceRetries,
385 )
386}
387
388func (c *Coordinator) scheduleParentPaneOAuthResumeAttempt(
389 ctx context.Context,
390 parentPaneID entity.PaneID,
391 popupID port.WebViewID,
392 parentURIAtOpen, callbackURI string,
393 remainingGrace int,
394) {
395 c.ensurePopupManager().schedulePopupRefresh(parentPaneID, oauthParentRefreshDebounce, func() {
396 cb := glib.SourceFunc(func(_ uintptr) bool {
397 c.resumeParentPaneAfterOAuthAttempt(ctx, parentPaneID, popupID, parentURIAtOpen, callbackURI, remainingGrace)
398 return false
399 })
400 glib.IdleAdd(&cb, 0)
401 })
402}
403
404func (c *Coordinator) resumeParentPaneAfterOAuth(
405 ctx context.Context,
406 parentPaneID entity.PaneID,
407 popupID port.WebViewID,
408 parentURIAtOpen, callbackURI string,
409) {
410 c.resumeParentPaneAfterOAuthAttempt(
411 ctx,
412 parentPaneID,
413 popupID,
414 parentURIAtOpen,
415 callbackURI,
416 oauthParentResumeGraceRetries,
417 )
418}
419
420func (c *Coordinator) resumeParentPaneAfterOAuthAttempt(
421 ctx context.Context,
422 parentPaneID entity.PaneID,
423 popupID port.WebViewID,
424 parentURIAtOpen, callbackURI string,
425 remainingGrace int,
426) {
427 log := logging.FromContext(ctx)
428 wv := c.getWebViewLocked(parentPaneID)
429 if wv == nil || wv.IsDestroyed() {
430 log.Debug().
431 Str("parent_pane_id", string(parentPaneID)).
432 Uint64("popup_id", uint64(popupID)).
433 Msg("skipping parent pane oauth resume: parent webview unavailable")
434 return
435 }
436
437 parentURI := strings.TrimSpace(wv.URI())
438 if parentURIAtOpen != "" && parentURI != "" && parentURI != parentURIAtOpen {
439 log.Info().
440 Str("parent_pane_id", string(parentPaneID)).
441 Uint64("popup_id", uint64(popupID)).
442 Str("parent_uri_at_open", logging.TruncateURL(parentURIAtOpen, logURLMaxLen)).
443 Str("current_parent_uri", logging.TruncateURL(parentURI, logURLMaxLen)).
444 Str("callback_uri", logging.TruncateURL(callbackURI, logURLMaxLen)).
445 Msg("skipping parent pane oauth resume: parent already navigated")
446 return
447 }
448
449 if shouldGraceWaitForParentPaneOAuthResume(parentURIAtOpen, parentURI, callbackURI, remainingGrace) {
450 log.Debug().
451 Str("parent_pane_id", string(parentPaneID)).
452 Uint64("popup_id", uint64(popupID)).
453 Int("remaining_grace", remainingGrace).
454 Str("parent_uri_at_open", logging.TruncateURL(parentURIAtOpen, logURLMaxLen)).
455 Str("callback_uri", logging.TruncateURL(callbackURI, logURLMaxLen)).
456 Msg("deferring parent pane oauth resume to allow in-flight same-site navigation to settle")
457 c.scheduleParentPaneOAuthResumeAttempt(
458 ctx,
459 parentPaneID,
460 popupID,
461 parentURIAtOpen,
462 callbackURI,
463 remainingGrace-1,
464 )
465 return
466 }
467
468 if err := wv.Reload(ctx); err != nil {
469 log.Warn().
470 Err(err).
471 Str("parent_pane_id", string(parentPaneID)).
472 Uint64("popup_id", uint64(popupID)).
473 Msg("failed parent pane refresh after oauth popup close")
474 return
475 }
476
477 log.Info().
478 Str("parent_pane_id", string(parentPaneID)).
479 Uint64("popup_id", uint64(popupID)).
480 Str("callback_uri", logging.TruncateURL(callbackURI, logURLMaxLen)).
481 Msg("refreshed parent pane after oauth popup close")
482}
483
484func shouldGraceWaitForParentPaneOAuthResume(
485 parentURIAtOpen, currentParentURI, callbackURI string,
486 remainingGrace int,
487) bool {
488 if remainingGrace <= 0 {
489 return false
490 }
491 if parentURIAtOpen == "" || currentParentURI == "" || callbackURI == "" {
492 return false
493 }
494 if currentParentURI != parentURIAtOpen {
495 return false
496 }
497 return sameOAuthResumeSite(parentURIAtOpen, callbackURI)
498}
499
500func sameOAuthResumeSite(a, b string) bool {
501 hostA := normalizeOAuthResumeHost(a)
502 hostB := normalizeOAuthResumeHost(b)
503 return hostA != "" && hostA == hostB
504}
505
506func normalizeOAuthResumeHost(raw string) string {
507 parsed, err := neturl.Parse(raw)
508 if err != nil {
509 return ""
510 }
511 host := strings.ToLower(strings.TrimSpace(parsed.Hostname()))
512 host = strings.TrimPrefix(host, "www.")
513 return host
514}