diff --git a/client/cmd/login.go b/client/cmd/login.go index 4e08334ebd6..afc823507b9 100644 --- a/client/cmd/login.go +++ b/client/cmd/login.go @@ -30,6 +30,7 @@ var extendSessionFlag bool func init() { loginCmd.PersistentFlags().BoolVar(&noBrowser, noBrowserFlag, false, noBrowserDesc) + loginCmd.PersistentFlags().BoolVar(&useDeviceAuth, useDeviceAuthFlag, false, useDeviceAuthDesc) loginCmd.PersistentFlags().BoolVar(&showQR, showQRFlag, false, showQRDesc) loginCmd.PersistentFlags().StringVar(&profileName, profileNameFlag, "", profileNameDesc) loginCmd.PersistentFlags().StringVarP(&configPath, "config", "c", "", "(DEPRECATED) Netbird config file location") @@ -137,6 +138,10 @@ func doDaemonLogin(ctx context.Context, cmd *cobra.Command, providedSetupKey str loginRequest.OptionalPreSharedKey = &preSharedKey } + if cmd.Flags().Changed(useDeviceAuthFlag) { + loginRequest.UseDeviceAuth = &useDeviceAuth + } + var loginErr error var loginResp *proto.LoginResponse @@ -406,7 +411,7 @@ func foregroundGetTokenInfo(ctx context.Context, cmd *cobra.Command, config *pro hint = profileState.Email } - oAuthFlow, err := auth.NewOAuthFlow(ctx, config, util.HasGraphicalSession(), false, hint) + oAuthFlow, err := auth.NewOAuthFlow(ctx, config, util.HasGraphicalSession(), useDeviceAuth, hint) if err != nil { return nil, err } diff --git a/client/cmd/up.go b/client/cmd/up.go index 2e53224df18..7a221bd5a52 100644 --- a/client/cmd/up.go +++ b/client/cmd/up.go @@ -41,6 +41,9 @@ const ( noBrowserFlag = "no-browser" noBrowserDesc = "do not open the browser for SSO login" + useDeviceAuthFlag = "use-device-auth" + useDeviceAuthDesc = "force the OAuth 2.0 Device Authorization Grant instead of the PKCE/browser flow" + showQRFlag = "qr" showQRDesc = "show QR code for the SSO login URL (useful for headless machines without browser access)" @@ -55,6 +58,7 @@ var ( dnsLabels []string dnsLabelsValidated domain.List noBrowser bool + useDeviceAuth bool showQR bool profileName string configPath string @@ -88,6 +92,7 @@ func init() { ) upCmd.PersistentFlags().BoolVar(&noBrowser, noBrowserFlag, false, noBrowserDesc) + upCmd.PersistentFlags().BoolVar(&useDeviceAuth, useDeviceAuthFlag, false, useDeviceAuthDesc) upCmd.PersistentFlags().BoolVar(&showQR, showQRFlag, false, showQRDesc) upCmd.PersistentFlags().StringVar(&profileName, profileNameFlag, "", profileNameDesc) upCmd.PersistentFlags().StringVarP(&configPath, "config", "c", "", "(DEPRECATED) NetBird config file location. ") @@ -731,6 +736,10 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte loginRequest.OptionalPreSharedKey = &preSharedKey } + if cmd.Flags().Changed(useDeviceAuthFlag) { + loginRequest.UseDeviceAuth = &useDeviceAuth + } + if cmd.Flag(enableRosenpassFlag).Changed { loginRequest.RosenpassEnabled = &rosenpassEnabled } diff --git a/client/internal/auth/oauth_test.go b/client/internal/auth/oauth_test.go new file mode 100644 index 00000000000..056f792fc31 --- /dev/null +++ b/client/internal/auth/oauth_test.go @@ -0,0 +1,20 @@ +package auth + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestShouldUseDeviceFlow_ForcedAlwaysTrue(t *testing.T) { + // The --use-device-auth flag relies on force=true overriding the + // automatic PKCE-vs-device selection, even on a desktop client where + // PKCE would otherwise be chosen. + assert.True(t, shouldUseDeviceFlow(true, true), "force should select device flow on a desktop client") + assert.True(t, shouldUseDeviceFlow(true, false), "force should select device flow on a non-desktop client") +} + +func TestShouldUseDeviceFlow_NotForcedRespectsDesktop(t *testing.T) { + // Without force, a desktop client must not be pushed to device flow. + assert.False(t, shouldUseDeviceFlow(false, true), "desktop client without force should not use device flow") +} diff --git a/client/proto/daemon.pb.go b/client/proto/daemon.pb.go index 7f3ce1bbf6c..e532dad8ca6 100644 --- a/client/proto/daemon.pb.go +++ b/client/proto/daemon.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.6 -// protoc v6.33.1 +// protoc v7.35.1 // source: daemon.proto package proto @@ -348,8 +348,11 @@ type LoginRequest struct { // remoteJobsAllowed opts the peer into management-requested remote jobs // (e.g. debug bundles). Absent leaves the stored value unchanged. RemoteJobsAllowed *bool `protobuf:"varint,43,opt,name=remoteJobsAllowed,proto3,oneof" json:"remoteJobsAllowed,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // useDeviceAuth forces the OAuth 2.0 Device Authorization Grant instead of + // the PKCE/browser flow. + UseDeviceAuth *bool `protobuf:"varint,44,opt,name=useDeviceAuth,proto3,oneof" json:"useDeviceAuth,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *LoginRequest) Reset() { @@ -684,6 +687,13 @@ func (x *LoginRequest) GetRemoteJobsAllowed() bool { return false } +func (x *LoginRequest) GetUseDeviceAuth() bool { + if x != nil && x.UseDeviceAuth != nil { + return *x.UseDeviceAuth + } + return false +} + type LoginResponse struct { state protoimpl.MessageState `protogen:"open.v1"` NeedsSSOLogin bool `protobuf:"varint,1,opt,name=needsSSOLogin,proto3" json:"needsSSOLogin,omitempty"` @@ -7092,7 +7102,7 @@ var File_daemon_proto protoreflect.FileDescriptor const file_daemon_proto_rawDesc = "" + "\n" + "\fdaemon.proto\x12\x06daemon\x1a google/protobuf/descriptor.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1egoogle/protobuf/duration.proto\"\x0e\n" + - "\fEmptyRequest\"\xdb\x14\n" + + "\fEmptyRequest\"\x98\x15\n" + "\fLoginRequest\x12\x1a\n" + "\bsetupKey\x18\x01 \x01(\tR\bsetupKey\x12&\n" + "\fpreSharedKey\x18\x02 \x01(\tB\x02\x18\x01R\fpreSharedKey\x12$\n" + @@ -7140,7 +7150,8 @@ const file_daemon_proto_rawDesc = "" + "\fdisable_ipv6\x18( \x01(\bH\x1bR\vdisableIpv6\x88\x01\x01\x125\n" + "\x14enable_local_metrics\x18) \x01(\bH\x1cR\x12enableLocalMetrics\x88\x01\x01\x127\n" + "\x15local_metrics_address\x18* \x01(\tH\x1dR\x13localMetricsAddress\x88\x01\x01\x121\n" + - "\x11remoteJobsAllowed\x18+ \x01(\bH\x1eR\x11remoteJobsAllowed\x88\x01\x01B\x13\n" + + "\x11remoteJobsAllowed\x18+ \x01(\bH\x1eR\x11remoteJobsAllowed\x88\x01\x01\x12)\n" + + "\ruseDeviceAuth\x18, \x01(\bH\x1fR\ruseDeviceAuth\x88\x01\x01B\x13\n" + "\x11_rosenpassEnabledB\x10\n" + "\x0e_interfaceNameB\x10\n" + "\x0e_wireguardPortB\x17\n" + @@ -7171,7 +7182,8 @@ const file_daemon_proto_rawDesc = "" + "\r_disable_ipv6B\x17\n" + "\x15_enable_local_metricsB\x18\n" + "\x16_local_metrics_addressB\x14\n" + - "\x12_remoteJobsAllowed\"\xb5\x01\n" + + "\x12_remoteJobsAllowedB\x10\n" + + "\x0e_useDeviceAuth\"\xb5\x01\n" + "\rLoginResponse\x12$\n" + "\rneedsSSOLogin\x18\x01 \x01(\bR\rneedsSSOLogin\x12\x1a\n" + "\buserCode\x18\x02 \x01(\tR\buserCode\x12(\n" + diff --git a/client/proto/daemon.proto b/client/proto/daemon.proto index 3953f9c15ef..198e801aab0 100644 --- a/client/proto/daemon.proto +++ b/client/proto/daemon.proto @@ -248,6 +248,9 @@ message LoginRequest { // remoteJobsAllowed opts the peer into management-requested remote jobs // (e.g. debug bundles). Absent leaves the stored value unchanged. optional bool remoteJobsAllowed = 43; + // useDeviceAuth forces the OAuth 2.0 Device Authorization Grant instead of + // the PKCE/browser flow. + optional bool useDeviceAuth = 44; } message LoginResponse { diff --git a/client/proto/daemon_grpc.pb.go b/client/proto/daemon_grpc.pb.go index 2d01d474dd2..f294c98af0c 100644 --- a/client/proto/daemon_grpc.pb.go +++ b/client/proto/daemon_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.1 -// - protoc v6.33.1 +// - protoc v7.35.1 // source: daemon.proto package proto diff --git a/client/server/server.go b/client/server/server.go index a38bbe8ad0f..70e53834c2b 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -742,13 +742,13 @@ func (s *Server) beginSSOLogin(ctx context.Context, config *profilemanager.Confi if msg.Hint != nil { hint = *msg.Hint } - oAuthFlow, err := auth.NewOAuthFlow(ctx, config, msg.IsUnixDesktopClient, false, hint) + oAuthFlow, err := auth.NewOAuthFlow(ctx, config, msg.IsUnixDesktopClient, msg.GetUseDeviceAuth(), hint) if err != nil { state.Set(internal.StatusLoginFailed) return nil, err } - if resp := s.pendingOAuthFlowResponse(ctx, oAuthFlow); resp != nil { + if resp := s.pendingOAuthFlowResponse(ctx, oAuthFlow, msg.GetUseDeviceAuth()); resp != nil { state.Set(internal.StatusNeedsLogin) return resp, nil } @@ -780,11 +780,16 @@ func (s *Server) beginSSOLogin(ctx context.Context, config *profilemanager.Confi // the browser leg, so a second login joins the pending flow instead of opening // a competing one. A flow too close to expiry has its waiter cancelled and nil // returned, leaving the caller to start a fresh flow. -func (s *Server) pendingOAuthFlowResponse(ctx context.Context, oAuthFlow auth.OAuthFlow) *proto.LoginResponse { +func (s *Server) pendingOAuthFlowResponse(ctx context.Context, oAuthFlow auth.OAuthFlow, useDeviceAuth bool) *proto.LoginResponse { if s.oauthAuthFlow.flow == nil || s.oauthAuthFlow.flow.GetClientID(ctx) != oAuthFlow.GetClientID(ctx) { return nil } + _, cachedIsDevice := s.oauthAuthFlow.flow.(*auth.DeviceAuthorizationFlow) + if cachedIsDevice != useDeviceAuth { + return nil + } + if s.oauthAuthFlow.expiresAt.After(time.Now().Add(90 * time.Second)) { log.Debugf("using previous oauth flow info") return &proto.LoginResponse{