mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-04 19:55:09 -04:00
## Describe your changes After the SSO session expires, the daemon tears the engine down permanently (management returns `PermissionDenied` → `runCancel()` → the retry loop exits for good). The "Session expired" dialog's Login button still drove the extend-session flow, which requires a live engine: the user completed the full browser SSO + 2FA round trip only to get `Failed to extend the session — engine is not initialised`, with no way out other than quitting and relaunching the client. Reproduce: 1. Log in on a desktop client with session expiration enabled (e.g. 16h TTL). 2. Let the session expire (e.g. leave the machine asleep overnight). 3. Wake it, click **Login** on the "Session expired" dialog, complete SSO + 2FA. 4. The error dialog appears and every retry fails the same way. Changes: - The expired branch of the session-expiration dialog now emits `trigger-login`, driving the full `Login → SSO → Up` sequence that rebuilds the client, instead of the extend flow (an expired session can no longer be extended). - `RequestExtendAuthSession` fails fast when the engine is already gone, so the browser/2FA round trip is not wasted on a doomed extend. - The expired tray row navigated the main window to `/#/login`, a route that does not exist and fell through to the main page without starting a login; it now emits `trigger-login` as well. ## Issue ticket number and link <!-- Required for anything that changes behavior. Link the issue (or the validated discussion it came from) that the NetBird team already agreed on. See https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#ticket-first-pr-second --> ## Stack <!-- branch-stack --> ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [ ] I ran and tested this change locally — I did not rely on CI to find out whether it works - [ ] This PR has a single purpose (not a fix + refactor + feature in one) - [ ] This change is a trivial fix, **OR** it links an issue the NetBird team agreed on beforehand. Changes to the public API, gRPC protocols, functionality behavior, CLI / service flags, or new features always need that agreement first. See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#ticket-first-pr-second). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **Bug Fixes** - Improved session extension handling when the client engine is unavailable by prompting users to log in again. - Updated expired-session behavior to trigger the standard login flow, providing a more consistent sign-in experience. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
318 lines
11 KiB
Go
318 lines
11 KiB
Go
//go:build !android && !ios && !freebsd && !js
|
|
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"strconv"
|
|
"time"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
"github.com/wailsapp/wails/v3/pkg/services/notifications"
|
|
|
|
nbstatus "github.com/netbirdio/netbird/client/status"
|
|
"github.com/netbirdio/netbird/client/ui/authsession"
|
|
"github.com/netbirdio/netbird/client/ui/services"
|
|
)
|
|
|
|
const (
|
|
notifyIDSessionExpired = "netbird-session-expired"
|
|
notifyIDSessionWarning = "netbird-session-warning"
|
|
|
|
notifyCategorySessionWarning = "netbird-session-warning"
|
|
notifyActionExtendNow = "extend-now"
|
|
notifyActionDismiss = "dismiss"
|
|
|
|
// finalWarningCountdownSeconds must stay in sync by hand with sessionwatch.FinalWarningLead.
|
|
finalWarningCountdownSeconds = 120
|
|
)
|
|
|
|
// handleSessionExpired notifies and brings the window forward so the user can reconnect.
|
|
func (t *Tray) handleSessionExpired() {
|
|
t.notify(t.loc.T("notify.sessionExpired.title"), t.loc.T("notify.sessionExpired.body"), notifyIDSessionExpired)
|
|
if t.window != nil {
|
|
t.window.Show()
|
|
t.window.Focus()
|
|
}
|
|
}
|
|
|
|
// applySessionExpiry refreshes the cached SSO deadline and reports whether it changed.
|
|
// Cache-only; the caller relayouts when this returns true.
|
|
func (t *Tray) applySessionExpiry(deadline *time.Time, connected bool) bool {
|
|
var d time.Time
|
|
if connected && deadline != nil {
|
|
d = *deadline
|
|
}
|
|
|
|
t.sessionMu.Lock()
|
|
changed := !t.sessionExpiresAt.Equal(d)
|
|
t.sessionExpiresAt = d
|
|
t.sessionMu.Unlock()
|
|
|
|
if changed {
|
|
switch {
|
|
case deadline == nil:
|
|
log.Infof("tray applySessionExpiry: deadline=<nil> connected=%v → row hidden", connected)
|
|
case deadline.IsZero():
|
|
log.Infof("tray applySessionExpiry: deadline=<zero> connected=%v → row hidden", connected)
|
|
default:
|
|
log.Infof("tray applySessionExpiry: deadline=%s (in %s) connected=%v",
|
|
deadline.Format(time.RFC3339), time.Until(*deadline), connected)
|
|
}
|
|
}
|
|
return changed
|
|
}
|
|
|
|
// runSessionExpiryTicker recomputes the "Expires in …" row label until process exit.
|
|
// The interval scales with the remaining time: coarse when the deadline is far off,
|
|
// down to 10s in the final two minutes so the label doesn't lag the ceiling-rounded
|
|
// countdown near expiry. The cached deadline is re-read every iteration, so an extend
|
|
// or reconnect that moves it is picked up on the next tick.
|
|
func (t *Tray) runSessionExpiryTicker() {
|
|
tm := time.NewTimer(sessionRefreshInterval(t.sessionRemaining()))
|
|
defer tm.Stop()
|
|
for range tm.C {
|
|
t.refreshSessionExpiresLabel()
|
|
tm.Reset(sessionRefreshInterval(t.sessionRemaining()))
|
|
}
|
|
}
|
|
|
|
// sessionRemaining returns the time left on the cached SSO deadline, or 0 when unknown.
|
|
func (t *Tray) sessionRemaining() time.Duration {
|
|
t.sessionMu.Lock()
|
|
deadline := t.sessionExpiresAt
|
|
t.sessionMu.Unlock()
|
|
if deadline.IsZero() {
|
|
return 0
|
|
}
|
|
return time.Until(deadline)
|
|
}
|
|
|
|
// sessionRefreshInterval picks how long to wait before the next label recompute.
|
|
func sessionRefreshInterval(remaining time.Duration) time.Duration {
|
|
switch {
|
|
case remaining <= 0:
|
|
return 30 * time.Second
|
|
case remaining <= 2*time.Minute:
|
|
return 10 * time.Second
|
|
case remaining <= time.Hour:
|
|
return 30 * time.Second
|
|
default:
|
|
return time.Minute
|
|
}
|
|
}
|
|
|
|
// refreshSessionExpiresLabel updates only the countdown label, no relayout, to avoid disturbing an open menu.
|
|
// The item is snapshotted under menuMu since buildMenu reassigns it on every relayout.
|
|
func (t *Tray) refreshSessionExpiresLabel() {
|
|
t.menuMu.Lock()
|
|
item := t.sessionExpiresItem
|
|
t.menuMu.Unlock()
|
|
if item == nil {
|
|
return
|
|
}
|
|
t.sessionMu.Lock()
|
|
deadline := t.sessionExpiresAt
|
|
t.sessionMu.Unlock()
|
|
if deadline.IsZero() {
|
|
return
|
|
}
|
|
item.SetLabel(t.sessionRowLabel(deadline))
|
|
}
|
|
|
|
func (t *Tray) sessionRowLabel(deadline time.Time) string {
|
|
remaining := time.Until(deadline)
|
|
if remaining <= 0 {
|
|
return t.loc.T("tray.status.sessionExpired")
|
|
}
|
|
return t.loc.T("tray.session.expiresIn", "remaining", t.formatSessionRemaining(remaining))
|
|
}
|
|
|
|
// formatSessionRemaining renders d as a localised long-form string picking the largest non-zero unit.
|
|
// Each unit is rounded up so the label never claims less time than actually remains, matching the
|
|
// upper-bound sense of the sub-minute "less than a minute" fragment.
|
|
// Singular/plural keys are split per language for proper translation.
|
|
func (t *Tray) formatSessionRemaining(d time.Duration) string {
|
|
switch {
|
|
case d < time.Minute:
|
|
return t.loc.T("tray.session.unit.lessThanMinute")
|
|
case d <= 59*time.Minute:
|
|
m := ceilDiv(d, time.Minute)
|
|
if m == 1 {
|
|
return t.loc.T("tray.session.unit.minute")
|
|
}
|
|
return t.loc.T("tray.session.unit.minutes", "count", strconv.Itoa(m))
|
|
case d <= 23*time.Hour:
|
|
h := ceilDiv(d, time.Hour)
|
|
if h == 1 {
|
|
return t.loc.T("tray.session.unit.hour")
|
|
}
|
|
return t.loc.T("tray.session.unit.hours", "count", strconv.Itoa(h))
|
|
default:
|
|
days := ceilDiv(d, 24*time.Hour)
|
|
if days == 1 {
|
|
return t.loc.T("tray.session.unit.day")
|
|
}
|
|
return t.loc.T("tray.session.unit.days", "count", strconv.Itoa(days))
|
|
}
|
|
}
|
|
|
|
// ceilDiv divides d by unit rounding up, assuming d > 0.
|
|
func ceilDiv(d, unit time.Duration) int {
|
|
return int((d + unit - time.Nanosecond) / unit)
|
|
}
|
|
|
|
// registerSessionWarningCategory wires the OS notification category and response handler for the expiry warning.
|
|
// Errors are swallowed since the worst case is a plain notification without buttons.
|
|
func (t *Tray) registerSessionWarningCategory() {
|
|
if t.svc.Notifier == nil {
|
|
return
|
|
}
|
|
if err := t.svc.Notifier.RegisterNotificationCategory(notifications.NotificationCategory{
|
|
ID: notifyCategorySessionWarning,
|
|
Actions: []notifications.NotificationAction{
|
|
{ID: notifyActionExtendNow, Title: t.loc.T("notify.sessionWarning.extend")},
|
|
{ID: notifyActionDismiss, Title: t.loc.T("notify.sessionWarning.dismiss")},
|
|
},
|
|
}); err != nil {
|
|
log.Debugf("register session-warning notification category: %v", err)
|
|
}
|
|
t.svc.Notifier.OnNotificationResponse(func(result notifications.NotificationResult) {
|
|
if result.Error != nil {
|
|
log.Debugf("notification response error: %v", result.Error)
|
|
return
|
|
}
|
|
if result.Response.CategoryID != notifyCategorySessionWarning {
|
|
return
|
|
}
|
|
switch result.Response.ActionIdentifier {
|
|
case notifyActionExtendNow, notifications.DefaultActionIdentifier:
|
|
// DefaultActionIdentifier is the body-click on platforms with no separate buttons; treat as Extend.
|
|
go t.runExtendSession()
|
|
case notifyActionDismiss:
|
|
go t.dismissSessionWarning()
|
|
}
|
|
})
|
|
}
|
|
|
|
// buildSessionWarningBody composes the localised notification body from the daemon's metadata.
|
|
// The daemon has no locale, so it ships an RFC3339 deadline the tray turns into a user-language sentence.
|
|
// Falls back to a generic string when metadata is missing or unparsable.
|
|
func (t *Tray) buildSessionWarningBody(meta map[string]string) string {
|
|
if meta == nil {
|
|
return t.loc.T("notify.sessionWarning.bodyGeneric")
|
|
}
|
|
raw := meta[authsession.MetaExpiresAt]
|
|
if raw == "" {
|
|
return t.loc.T("notify.sessionWarning.bodyGeneric")
|
|
}
|
|
deadline, err := authsession.ParseExpiresAt(raw)
|
|
if err != nil {
|
|
return t.loc.T("notify.sessionWarning.bodyGeneric")
|
|
}
|
|
remaining := nbstatus.FormatRemainingDuration(time.Until(deadline))
|
|
return t.loc.T("notify.sessionWarning.body", "remaining", remaining)
|
|
}
|
|
|
|
// notifySessionWarning sends the interactive expiry notification, falling back to plain notify when the
|
|
// with-actions variant is unavailable (older platform impls, or a bare Notifier in tests).
|
|
func (t *Tray) notifySessionWarning(title, body string) {
|
|
if t.svc.Notifier == nil {
|
|
return
|
|
}
|
|
err := safeSendNotification(t.svc.Notifier.SendNotificationWithActions, "session-warning with actions", notifications.NotificationOptions{
|
|
ID: notifyIDSessionWarning,
|
|
Title: title,
|
|
Body: body,
|
|
CategoryID: notifyCategorySessionWarning,
|
|
})
|
|
if err != nil {
|
|
// A recovered panic returns nil err, so a dead bus correctly skips this fallback (it would panic too).
|
|
t.notify(title, body, notifyIDSessionWarning)
|
|
}
|
|
}
|
|
|
|
// runExtendSession drives the daemon's RequestExtend + WaitExtend pair, opening the browser via Connection.OpenURL.
|
|
// Errors surface as notifyError rather than foreground UI, since the warning may fire while the window is closed.
|
|
func (t *Tray) runExtendSession() {
|
|
if t.svc.Session == nil || t.svc.Connection == nil {
|
|
log.Debugf("session-warning: extend requested but services not wired")
|
|
return
|
|
}
|
|
ctx := context.Background()
|
|
|
|
start, err := t.svc.Session.RequestExtend(ctx, services.ExtendStartParams{})
|
|
if err != nil {
|
|
log.Warnf("session-warning: RequestExtend failed: %v", err)
|
|
t.notifyError(t.loc.T("notify.sessionWarning.failed"))
|
|
return
|
|
}
|
|
|
|
uri := start.VerificationURIComplete
|
|
if uri == "" {
|
|
uri = start.VerificationURI
|
|
}
|
|
if uri != "" {
|
|
if err := t.svc.Connection.OpenURL(uri); err != nil {
|
|
log.Debugf("session-warning: opening verification URL: %v", err)
|
|
}
|
|
}
|
|
|
|
result, err := t.svc.Session.WaitExtend(ctx, services.ExtendWaitParams{
|
|
DeviceCode: start.DeviceCode,
|
|
UserCode: start.UserCode,
|
|
})
|
|
if err != nil {
|
|
log.Warnf("session-warning: WaitExtend failed: %v", err)
|
|
t.notifyError(t.loc.T("notify.sessionWarning.failed"))
|
|
return
|
|
}
|
|
if result.Preempted {
|
|
// Another UI surface owns the flow; stay silent so the user only sees the surviving flow's outcome.
|
|
log.Debugf("session-warning: WaitExtend preempted by a newer flow")
|
|
return
|
|
}
|
|
t.notify(t.loc.T("notify.sessionWarning.successTitle"), t.loc.T("notify.sessionWarning.successBody"), notifyIDSessionWarning)
|
|
}
|
|
|
|
// dismissSessionWarning tells the daemon to silence the fallback dialog for the current deadline.
|
|
// Best-effort: a failure only means the dialog will still appear.
|
|
func (t *Tray) dismissSessionWarning() {
|
|
if t.svc.Session == nil {
|
|
return
|
|
}
|
|
if err := t.svc.Session.DismissWarning(context.Background()); err != nil {
|
|
log.Debugf("session-warning: DismissWarning failed: %v", err)
|
|
}
|
|
}
|
|
|
|
// openSessionExpiration fires the fallback dialog when the earlier warning notification wasn't dismissed.
|
|
// Idempotent on the WindowManager side.
|
|
func (t *Tray) openSessionExpiration() {
|
|
if t.svc.WindowManager == nil {
|
|
return
|
|
}
|
|
t.svc.WindowManager.OpenSessionExpiration(finalWarningCountdownSeconds)
|
|
}
|
|
|
|
// openSessionExtendFlow opens the SessionExpiration window seeded with the cached deadline's remaining time,
|
|
// for the "Expires in …" tray row. Once the deadline has elapsed the row reads "Session expired" and the
|
|
// click routes to the login flow instead. No-op when the deadline is unknown.
|
|
func (t *Tray) openSessionExtendFlow() {
|
|
t.sessionMu.Lock()
|
|
deadline := t.sessionExpiresAt
|
|
t.sessionMu.Unlock()
|
|
if deadline.IsZero() {
|
|
return
|
|
}
|
|
seconds := int(time.Until(deadline).Seconds())
|
|
if seconds <= 0 {
|
|
t.app.Event.Emit(services.EventTriggerLogin)
|
|
return
|
|
}
|
|
if t.svc.WindowManager == nil {
|
|
return
|
|
}
|
|
t.svc.WindowManager.OpenSessionExpiration(seconds)
|
|
}
|