v0.33.2 57f21d6

oauth.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 9

Tags 42

↑↓ moveenter openesc close