add SSO session extend flow

Adds an end-to-end SSO session-extension feature: the management server
publishes per-peer session deadlines on every Login/Sync, a new
ExtendAuthSession RPC refreshes the deadline using a fresh JWT without
tearing down the tunnel, and the daemon tracks the deadline locally so
the UI can fire a T-10min warning toast with an interactive "Extend now"
action.
This commit is contained in:
Zoltán Papp
2026-05-20 16:43:14 +02:00
parent 2aea1f7bb5
commit ef6b4f7538
47 changed files with 6506 additions and 2603 deletions
+50 -28
View File
@@ -15,6 +15,7 @@ import (
"google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/client/ui/authsession"
"github.com/netbirdio/netbird/client/ui/updater"
)
@@ -34,6 +35,14 @@ const (
// others without polling. The daemon itself does not emit a profile
// event, so this is the only signal that closes the gap.
EventProfileChanged = "netbird:profile:changed"
// EventSessionWarning is emitted on every session-warning watcher
// fire (T-WarningLead and T-FinalWarningLead) as a strongly-typed
// sibling of EventSystem so React / tray subscribers don't have to
// filter the firehose of EventSystem. Consumers branch on the
// SessionWarning.Final flag to tell the interactive T-10 event apart
// from the fallback T-2 event; the dialog auto-open lives in the
// tray (Go side) so the frontend stays passive on this flow.
EventSessionWarning = "netbird:session:warning"
// StatusDaemonUnavailable is the synthetic Status the UI emits when the
// daemon's gRPC socket is unreachable (daemon not running, socket
@@ -43,11 +52,11 @@ const (
// Daemon connection status strings — mirror internal.Status* in
// client/internal/state.go.
StatusConnected = "Connected"
StatusConnecting = "Connecting"
StatusIdle = "Idle"
StatusNeedsLogin = "NeedsLogin"
StatusLoginFailed = "LoginFailed"
StatusConnected = "Connected"
StatusConnecting = "Connecting"
StatusIdle = "Idle"
StatusNeedsLogin = "NeedsLogin"
StatusLoginFailed = "LoginFailed"
StatusSessionExpired = "SessionExpired"
)
@@ -110,13 +119,19 @@ type LocalPeer struct {
// Status is the snapshot the frontend renders on the dashboard.
type Status struct {
Status string `json:"status"`
DaemonVersion string `json:"daemonVersion"`
Management PeerLink `json:"management"`
Signal PeerLink `json:"signal"`
Local LocalPeer `json:"local"`
Peers []PeerStatus `json:"peers"`
Status string `json:"status"`
DaemonVersion string `json:"daemonVersion"`
Management PeerLink `json:"management"`
Signal PeerLink `json:"signal"`
Local LocalPeer `json:"local"`
Peers []PeerStatus `json:"peers"`
Events []SystemEvent `json:"events"`
// SessionExpiresAt is the absolute UTC instant at which the peer's
// SSO session expires. nil when the peer is not SSO-tracked or login
// expiration is disabled (either server-side off, or peer not
// SSO-registered). The UI derives "warning active" from this value
// plus its own clock.
SessionExpiresAt *time.Time `json:"sessionExpiresAt,omitempty"`
}
// Peers serves the dashboard data: one polled Status RPC and a long-running
@@ -277,23 +292,6 @@ func (s *Peers) Get(ctx context.Context) (Status, error) {
return statusFromProto(resp), nil
}
// isDaemonUnreachable reports whether a gRPC stream error indicates the
// daemon socket itself is not answering (process down, socket missing,
// permission denied) versus the daemon responding with an application-level
// error code. Only the former should flip the tray to "Not running" — a
// daemon that returns FailedPrecondition (e.g. while it's retrying the
// management connection) is alive and shouldn't be reported as down.
func isDaemonUnreachable(err error) bool {
if err == nil {
return false
}
st, ok := status.FromError(err)
if !ok {
return true
}
return st.Code() == codes.Unavailable
}
// statusStreamLoop subscribes to the daemon's SubscribeStatus stream and
// re-emits each FullStatus snapshot on the Wails event bus. The first
// message is the current snapshot; subsequent messages fire on
@@ -406,6 +404,9 @@ func (s *Peers) toastStreamLoop(ctx context.Context) {
se := systemEventFromProto(ev)
log.Infof("backend event: system severity=%s category=%s msg=%q", se.Severity, se.Category, se.UserMessage)
s.emitter.Emit(EventSystem, se)
if warn, ok := authsession.WarningFromMetadata(se.Metadata); ok {
s.emitter.Emit(EventSessionWarning, warn)
}
if s.updater != nil {
s.updater.OnSystemEvent(ev)
}
@@ -468,6 +469,10 @@ func statusFromProto(resp *proto.StatusResponse) Status {
for _, e := range full.GetEvents() {
st.Events = append(st.Events, systemEventFromProto(e))
}
if ts := resp.GetSessionExpiresAt(); ts.IsValid() && !ts.AsTime().IsZero() {
t := ts.AsTime().UTC()
st.SessionExpiresAt = &t
}
return st
}
@@ -488,3 +493,20 @@ func systemEventFromProto(e *proto.SystemEvent) SystemEvent {
}
return out
}
// isDaemonUnreachable reports whether a gRPC stream error indicates the
// daemon socket itself is not answering (process down, socket missing,
// permission denied) versus the daemon responding with an application-level
// error code. Only the former should flip the tray to "Not running" — a
// daemon that returns FailedPrecondition (e.g. while it's retrying the
// management connection) is alive and shouldn't be reported as down.
func isDaemonUnreachable(err error) bool {
if err == nil {
return false
}
st, ok := status.FromError(err)
if !ok {
return true
}
return st.Code() == codes.Unavailable
}
+48
View File
@@ -0,0 +1,48 @@
//go:build !android && !ios && !freebsd && !js
package services
import (
"context"
"github.com/netbirdio/netbird/client/ui/authsession"
)
// Re-exports so frontend bindings stay on services.ExtendStart* /
// services.ExtendWait* / services.ExtendResult without each call site
// importing authsession.
type (
ExtendStartParams = authsession.ExtendStartParams
ExtendStartResult = authsession.ExtendStartResult
ExtendWaitParams = authsession.ExtendWaitParams
ExtendResult = authsession.ExtendResult
)
// Session is the Wails-bound wrapper around authsession.Session. It only
// re-exposes the subset the React frontend actually calls
// (SessionAboutToExpireDialog.tsx: RequestExtend + WaitExtend). The tray
// uses authsession.Session directly, so methods that only the tray needs
// (DismissWarning) are deliberately absent here — keeping the generated
// TS surface minimal.
type Session struct {
inner *authsession.Session
}
// NewSession returns the Wails-bound wrapper. The caller owns the inner
// authsession.Session and may use it directly (e.g. the tray).
func NewSession(inner *authsession.Session) *Session {
return &Session{inner: inner}
}
// RequestExtend starts the SSO session-extension flow on the daemon and
// returns the verification URI for the UI to open.
func (s *Session) RequestExtend(ctx context.Context, p ExtendStartParams) (ExtendStartResult, error) {
return s.inner.RequestExtend(ctx, p)
}
// WaitExtend blocks until the user completes the SSO flow started by
// RequestExtend, then returns the new session deadline (or nil when the
// management server reports the peer ineligible).
func (s *Session) WaitExtend(ctx context.Context, p ExtendWaitParams) (ExtendResult, error) {
return s.inner.WaitExtend(ctx, p)
}