v0.28.0 3cb3770

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(
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}

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 8

Tags 42

↑↓ moveenter openesc close