Repository: iterative/dvc
Pull request: studio: Add Studio authentication to DVC
* [x] ❗ I have followed the [Contributing to DVC](https://dvc.org/doc/user-guide/contributing/core) checklist.

* [x] 📖 If this PR requires [documentation](https://dvc.org/doc) updates, I have created a separate PR (or issue, at least) in [dvc.org](https://github.com/iterative/dvc.org) and linked it here.

Adds a new authentication command to DVC that authorizes dvc with Studio. 

Commands added are:
- `dvc studio login`
- `dvc studio logout`
- `dvc studio token`

## Studio command
```sh
usage: dvc studio [-h] [-q | -v] {login,logout,token} ...

Authorize dvc with Studio and set the token. When this is
set, DVC uses this to share live experiments and notify
Studio about pushed experiments.
Documentation: <https://man.dvc.org/studio>

positional arguments:
  {login,logout,token}  Use `dvc studio CMD --help` to display command-specific help.
    login               Authenticate DVC with Studio host
    logout              Logout user from Studio
    token               View the token dvc uses to contact Studio

options:
  -h, --help            show this help message and exit
  -q, --quiet           Be quiet.
  -v, --verbose         Be verbose.
```

## Login

To login, you can call `dvc studio login` which will open the Studio with a device code prefilled in your browser. Once the user authorizes the code, the token is saved to global config.
```sh
usage: dvc studio login [-h] [-q | -v] [-H HOSTNAME] [-s SCOPES] [-n NAME] [-d]

By default, this command authorize dvc with Studio with
 default scopes and a random  name as token name.
Documentation: <https://man.dvc.org/studio/login>

options:
  -h, --help            show this help message and exit
  -q, --quiet           Be quiet.
  -v, --verbose         Be verbose.
  -H HOSTNAME, --hostname HOSTNAME
                        The hostname of the Studio instance to authenticate with.
  -s SCOPES, --scopes SCOPES
                        The scopes for the authentication token.
  -n NAME, --name NAME  The name of the authentication token. It will be used to identify token shown in Studio profile.
  -d, --use-device-code
                        Use authentication flow based on user code. You will be presented with user code to enter in browser. DVC will also use this if it
                        cannot launch browser on your behalf.
```

### Example usages:

#### Normal flow
On running the command `dvc studio login` , following screen is presented in CLI:
```sh
dvc studio login
A web browser has been opened at
https://studio.iterative.ai/auth/device-login.
Please continue the login in the web browser.
If no web browser is available or if the web browser fails to open,
use device code flow with `dvc studio login --use-device-code`.
```

A webbrowser is opened with the device code in Studio.
<img width="632" alt="image" src="https://github.com/iterative/dvc/assets/16842655/8662d52b-9523-4db4-9742-02808d7e3ec4">
Once user authorizes the token in the screen above, the token is saved to the user's global config.
Additional message as below will be shown in the CLI and will exit.
```sh
Authentication successful. The token will be available as risen-geum in Studio profile.
```

#### Device login flow
In case, you are running this in remote machine or where you are unable to open web browser, the following screen is presented.
```
Please open the following url in your browser.
https://studio.iterative.ai/auth/device-login
And enter the user code below 06D21JBK to authorize.
```
Every other flow is same.

## Logout
```sh
usage: dvc studio logout [-h] [-q | -v]

This command helps to log out user from DVC Studio.

Documentation: <https://man.dvc.org/studio/logout>

options:
  -h, --help     show this help message and exit
  -q, --quiet    Be quiet.
  -v, --verbose  Be verbose.
```

## Token
```sh
usage: dvc studio token [-h] [-q | -v]

View the token dvc uses to contact Studio
Documentation: <https://man.dvc.org/studio/token>

options:
  -h, --help     show this help message and exit
  -q, --quiet    Be quiet.
  -v, --verbose  Be verbose.
```

TODO: 
- Needs https://github.com/iterative/studio/pull/8147 to be merged and deployed. 
- `dvc studio status` to check the status and scopes of existing token. This requires changes in Studio first.


Fixes https://github.com/iterative/dvc/issues/9265

Problem statement:
Studio token: make it global, make it easier to setup
Since token is being also used by VS Code we need to come up with a single way to manage it. I think it should be done in a way similar to `gh` app and not via DVC config per repo.

We have now a bit broken experience since we have it in two places and managed differently.

cc @mattseddon

Hints from issue discussion:
@shcheklein I added it to #9074. 

Unless I'm mistaken, it's already possible to make it global via `dvc config --global` (see #9213), so I think this is just about making it easy to set that up via some command that would automatically write to that global config, right?

Somewhat related to #9217. If we have some command like `dvc studio login` that always writes to the global config, we don't need to worry too much about users writing it to their project config.
> Unless I'm mistaken, it's already possible to make it global via dvc config --global (see https://github.com/iterative/dvc/pull/9213), so I think this is just about making it easy to set that up via some command that would automatically write to that global config, right?

Yep, it might be enough (btw, should we use `user` vs `global` - this names confuses me every time I use it). 

It is `—global`. I am -1 on adding a separate command for now. Let’s release what we have, and then come back to this. It’s too early to implement this at this time.
> It is —global. I am -1 on adding a separate command for now.

You mean `dvc studio login`? We can't add it w/o a proper Studio support anyways. I think it worth mentioning that it's not about making it a syntax sugar for the `dvc config` it goes way beyond that.
Yeah, let’s wait for feedback. `dvc studio login` and keyring support can be implemented when we get a good response to this. 
> You mean `dvc studio login`? We can't add it w/o a proper Studio support anyways. I think it worth mentioning that it's not about making it a syntax sugar for the `dvc config` it goes way beyond that.

@shcheklein Can you expand? I didn't get what you mean, and I'd also like to here how else you see it being used.

> Yeah, let’s wait for feedback. `dvc studio login` and keyring support can be implemented when we get a good response to this.

@skshetry I see your point and maybe it's not a blocker, but it seems harder to get a good response when we don't make it easy to setup. 

Another hybrid approach would be to keep this kind of experience limited to VS Code for now, but have VS Code save it to the global DVC config.
> @shcheklein Can you expand? I didn't get what you mean, and I'd also like to here how else you see it being used.

It has to reach out to Studio, open the browser, get the code back, save it to config. So, that's why it's not as simple as, let's say as `dvc cache dir`. 

Does it answer the question, Dave?

> Another hybrid approach would be to keep this kind of experience limited to VS Code for now, but have VS Code save it to the global DVC config.

My 2cs on this. It's easy to do on DVC and I don't see a single reason (considering the scope) of not doing that. But it would require Studio changes first - and that's where most of complexity is. VS Code itself won't help that much or improve the experience a lot.
We don’t have to implement everything. This is just the first iteration.

This is an integration, so we have to take both Studio and DVC into account. There are still other higher priority issues in Studio to iron out. I’d prefer that experience to be smooth first.

Let’s release what we have, get feedback from users, and iterate. It’s not good if we keep on increasing scope.


We should keep in mind that this it top of the funnel, @skshetry - means that more users potentially can try the whole combination of DVC + Studio + VS Code. It's important to optimize. I would still go with this from the Studio side- if we have there capacity or not.
@shcheklein, do you mind creating an issue on the Studio side, please?

Commit message: Update DVC Studio components and simplify authentication process

This commit modifies several parts of the ‘dvc/commands/studio.py’ and updates DVC Studio Client's version in 'pyproject.toml' for compatibility with the changes.

Primarily, the authentication process has been simplified. The unnecessary generation of a random token name is removed while initiating authorization in the run method of CmdStudioLogin class, enhancing the code quality.

Changes made in 'dvc/commands/studio.py' are mainly restructuring the code to adapt to the newer version of DVC Studio Client, including changes to the scopes and methods used from this client.

The corresponding changes were also reflected in unit tests. The test files 'tests/func/test_studio.py' and 'tests/unit/command/test_studio.py' are updated in accordance with the approach now used in 'dvc/commands/studio.py'.

Patch to review:
diff --git a/dvc/cli/parser.py b/dvc/cli/parser.py
index 88a60d1c2a..31e68ad4d0 100644
--- a/dvc/cli/parser.py
+++ b/dvc/cli/parser.py
@@ -42,6 +42,7 @@
     repro,
     root,
     stage,
+    studio,
     unprotect,
     update,
     version,
@@ -93,6 +94,7 @@
     check_ignore,
     data,
     artifacts,
+    studio,
 ]
 
 
diff --git a/dvc/commands/studio.py b/dvc/commands/studio.py
new file mode 100644
index 0000000000..6ef2c1eeb8
--- /dev/null
+++ b/dvc/commands/studio.py
@@ -0,0 +1,172 @@
+import argparse
+import os
+
+from funcy import get_in
+
+from dvc.cli.utils import append_doc_link, fix_subparsers
+from dvc.commands.config import CmdConfig
+from dvc.log import logger
+
+logger = logger.getChild(__name__)
+
+DEFAULT_SCOPES = "live,dvc_experiment,view_url,dql,download_model"
+
+
+class CmdStudioLogin(CmdConfig):
+    def run(self):
+        from dvc_studio_client.auth import StudioAuthError, initiate_authorization
+
+        from dvc.env import DVC_STUDIO_URL
+        from dvc.ui import ui
+        from dvc.utils.studio import STUDIO_URL
+
+        name = self.args.name
+        hostname = self.args.hostname or os.environ.get(DVC_STUDIO_URL) or STUDIO_URL
+        scopes = self.args.scopes or DEFAULT_SCOPES
+
+        try:
+            token_name, access_token = initiate_authorization(
+                name=name,
+                hostname=hostname,
+                scopes=scopes,
+                use_device_code=self.args.use_device_code,
+            )
+        except StudioAuthError as e:
+            ui.error_write(str(e))
+            return 1
+
+        self.save_config(hostname, access_token)
+        ui.write(
+            f"Authentication successful. The token will be "
+            f"available as {token_name} in Studio profile."
+        )
+        return 0
+
+    def save_config(self, hostname, token):
+        with self.config.edit("global") as conf:
+            conf["studio"]["token"] = token
+            conf["studio"]["url"] = hostname
+
+
+class CmdStudioLogout(CmdConfig):
+    def run(self):
+        from dvc.ui import ui
+
+        with self.config.edit("global") as conf:
+            if not get_in(conf, ["studio", "token"]):
+                ui.error_write("Not logged in to Studio.")
+                return 1
+
+            del conf["studio"]["token"]
+
+        ui.write("Logged out from Studio")
+        return 0
+
+
+class CmdStudioToken(CmdConfig):
+    def run(self):
+        from dvc.ui import ui
+
+        conf = self.config.read("global")
+        token = get_in(conf, ["studio", "token"])
+        if not token:
+            ui.error_write("Not logged in to Studio.")
+            return 1
+
+        ui.write(token)
+        return 0
+
+
+def add_parser(subparsers, parent_parser):
+    STUDIO_HELP = "Authenticate dvc with Iterative Studio"
+    STUDIO_DESCRIPTION = (
+        "Authorize dvc with Studio and set the token. When this is\n"
+        "set, DVC uses this to share live experiments and notify\n"
+        "Studio about pushed experiments."
+    )
+
+    studio_parser = subparsers.add_parser(
+        "studio",
+        parents=[parent_parser],
+        description=append_doc_link(STUDIO_DESCRIPTION, "studio"),
+        help=STUDIO_HELP,
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+    )
+    studio_subparser = studio_parser.add_subparsers(
+        dest="cmd",
+        help="Use `dvc studio CMD --help` to display command-specific help.",
+    )
+    fix_subparsers(studio_subparser)
+
+    STUDIO_LOGIN_HELP = "Authenticate DVC with Studio host"
+    STUDIO_LOGIN_DESCRIPTION = (
+        "By default, this command authorize dvc with Studio with\n"
+        " default scopes and a random  name as token name."
+    )
+    login_parser = studio_subparser.add_parser(
+        "login",
+        parents=[parent_parser],
+        description=append_doc_link(STUDIO_LOGIN_DESCRIPTION, "studio/login"),
+        help=STUDIO_LOGIN_HELP,
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+    )
+
+    login_parser.add_argument(
+        "-H",
+        "--hostname",
+        action="store",
+        default=None,
+        help="The hostname of the Studio instance to authenticate with.",
+    )
+    login_parser.add_argument(
+        "-s",
+        "--scopes",
+        action="store",
+        default=None,
+        help="The scopes for the authentication token. ",
+    )
+
+    login_parser.add_argument(
+        "-n",
+        "--name",
+        action="store",
+        default=None,
+        help="The name of the authentication token. It will be used to\n"
+        "identify token shown in Studio profile.",
+    )
+
+    login_parser.add_argument(
+        "-d",
+        "--use-device-code",
+        action="store_true",
+        default=False,
+        help="Use authentication flow based on user code.\n"
+        "You will be presented with user code to enter in browser.\n"
+        "DVC will also use this if it cannot launch browser on your behalf.",
+    )
+    login_parser.set_defaults(func=CmdStudioLogin)
+
+    STUDIO_LOGOUT_HELP = "Logout user from Studio"
+    STUDIO_LOGOUT_DESCRIPTION = "This command helps to log out user from DVC Studio.\n"
+
+    logout_parser = studio_subparser.add_parser(
+        "logout",
+        parents=[parent_parser],
+        description=append_doc_link(STUDIO_LOGOUT_DESCRIPTION, "studio/logout"),
+        help=STUDIO_LOGOUT_HELP,
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+    )
+
+    logout_parser.set_defaults(func=CmdStudioLogout)
+
+    STUDIO_TOKEN_HELP = "View the token dvc uses to contact Studio"  # noqa: S105 # nosec B105
+
+    logout_parser = studio_subparser.add_parser(
+        "token",
+        parents=[parent_parser],
+        description=append_doc_link(STUDIO_TOKEN_HELP, "studio/token"),
+        help=STUDIO_TOKEN_HELP,
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+    )
+
+    logout_parser.set_defaults(func=CmdStudioToken)
diff --git a/dvc/utils/studio.py b/dvc/utils/studio.py
index 2530db78b8..2df0bfced4 100644
--- a/dvc/utils/studio.py
+++ b/dvc/utils/studio.py
@@ -35,7 +35,7 @@ def post(
 
     logger.trace("Sending %s to %s", data, url)
 
-    headers = {"Authorization": f"token {token}"}
+    headers = {"Authorization": f"token {token}"} if token else None
     r = session.post(
         url, json=data, headers=headers, timeout=timeout, allow_redirects=False
     )
@@ -66,7 +66,7 @@ def notify_refs(
     try:
         r = post("webhook/dvc", token, data, base_url=base_url)
     except requests.RequestException as e:
-        logger.trace("", exc_info=True)
+        logger.trace("", exc_info=True)  # type: ignore[attr-defined]
 
         msg = str(e)
         if e.response is None:
diff --git a/pyproject.toml b/pyproject.toml
index 694fd180ec..56eb56476f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -37,7 +37,7 @@ dependencies = [
     "dvc-data>=2.22.0,<2.23.0",
     "dvc-http>=2.29.0",
     "dvc-render>=0.3.1,<1",
-    "dvc-studio-client>=0.13.0,<1",
+    "dvc-studio-client>=0.16.1,<1",
     "dvc-task>=0.3.0,<1",
     "flatten_dict<1,>=0.4.1",
     # https://github.com/iterative/dvc/issues/9654
diff --git a/tests/func/test_studio.py b/tests/func/test_studio.py
new file mode 100644
index 0000000000..1d35ccf5c2
--- /dev/null
+++ b/tests/func/test_studio.py
@@ -0,0 +1,105 @@
+from requests import Response
+
+from dvc.cli import main
+from dvc.commands.studio import DEFAULT_SCOPES
+from dvc.utils.studio import STUDIO_URL
+from tests.unit.command.test_studio import MOCK_RESPONSE
+
+
+def test_auth_expired(mocker, M):
+    mock_login_post = mocker.patch(
+        "requests.post", return_value=_mock_response(mocker, 200, MOCK_RESPONSE)
+    )
+    mock_poll_post = mocker.patch(
+        "requests.Session.post",
+        side_effect=[
+            _mock_response(mocker, 400, {"detail": "authorization_expired"}),
+        ],
+    )
+
+    assert main(["studio", "login"]) == 1
+
+    assert mock_login_post.call_args == mocker.call(
+        url=f"{STUDIO_URL}/api/device-login",
+        json={
+            "client_name": "dvc",
+            "scopes": DEFAULT_SCOPES.split(","),
+        },
+        headers={"Content-type": "application/json"},
+        timeout=5,
+    )
+
+    assert mock_poll_post.call_args_list == [
+        mocker.call(
+            f"{STUDIO_URL}/api/device-login/token",
+            json={"code": "random-value"},
+            timeout=5,
+            allow_redirects=False,
+        ),
+    ]
+
+
+def test_studio_success(mocker, dvc):
+    mocker.patch("time.sleep")
+    mock_login_post = mocker.patch(
+        "requests.post", return_value=_mock_response(mocker, 200, MOCK_RESPONSE)
+    )
+    mock_poll_post = mocker.patch(
+        "requests.Session.post",
+        side_effect=[
+            _mock_response(mocker, 400, {"detail": "authorization_pending"}),
+            _mock_response(mocker, 200, {"access_token": "isat_access_token"}),
+        ],
+    )
+
+    assert (
+        main(
+            [
+                "studio",
+                "login",
+                "--name",
+                "token_name",
+                "--hostname",
+                "https://example.com",
+                "--scopes",
+                "live",
+            ]
+        )
+        == 0
+    )
+
+    assert mock_login_post.call_args_list == [
+        mocker.call(
+            url="https://example.com/api/device-login",
+            json={"client_name": "dvc", "token_name": "token_name", "scopes": ["live"]},
+            headers={"Content-type": "application/json"},
+            timeout=5,
+        )
+    ]
+    assert mock_poll_post.call_count == 2
+    assert mock_poll_post.call_args_list == [
+        mocker.call(
+            f"{STUDIO_URL}/api/device-login/token",
+            json={"code": "random-value"},
+            timeout=5,
+            allow_redirects=False,
+        ),
+        mocker.call(
+            f"{STUDIO_URL}/api/device-login/token",
+            json={"code": "random-value"},
+            timeout=5,
+            allow_redirects=False,
+        ),
+    ]
+
+    config = dvc.config.load_one("global")
+    assert config["studio"]["token"] == "isat_access_token"
+    assert config["studio"]["url"] == "https://example.com"
+
+
+def _mock_response(mocker, status_code, json):
+    response = Response()
+    response.status_code = status_code
+    mocker.patch.object(response, "json", side_effect=[json])
+
+    return response
diff --git a/tests/unit/command/test_studio.py b/tests/unit/command/test_studio.py
new file mode 100644
index 0000000000..cecce5e01a
--- /dev/null
+++ b/tests/unit/command/test_studio.py
@@ -0,0 +1,126 @@
+from unittest import mock
+
+from dvc_studio_client.auth import AuthorizationExpired
+
+from dvc.cli import main
+from dvc.commands.studio import DEFAULT_SCOPES
+from dvc.utils.studio import STUDIO_URL
+
+MOCK_RESPONSE = {
+    "verification_uri": STUDIO_URL + "/auth/device-login",
+    "user_code": "MOCKCODE",
+    "device_code": "random-value",
+    "token_uri": STUDIO_URL + "/api/device-login/token",
+    "token_name": "random-name",
+}
+
+
+def test_studio_login_invalid_scope():
+    assert main(["studio", "login", "--scopes", "invalid!"]) == 1
+
+
+@mock.patch("dvc_studio_client.auth.check_token_authorization")
+@mock.patch("dvc_studio_client.auth.start_device_login")
+def test_studio_login_token_check_failed(
+    mock_start_device_login, mock_check_token_authorization
+):
+    mock_start_device_login.return_value = MOCK_RESPONSE
+    mock_check_token_authorization.side_effect = AuthorizationExpired
+
+    assert main(["studio", "login"]) == 1
+
+
+@mock.patch("dvc_studio_client.auth.check_token_authorization")
+@mock.patch("dvc_studio_client.auth.start_device_login")
+def test_studio_login_success(
+    mock_start_device_login, mock_check_token_authorization, dvc, M
+):
+    mock_start_device_login.return_value = MOCK_RESPONSE
+    mock_check_token_authorization.return_value = "isat_access_token"
+
+    assert main(["studio", "login"]) == 0
+    assert mock_start_device_login.call_args.kwargs == {
+        "base_url": STUDIO_URL,
+        "client_name": "dvc",
+        "scopes": DEFAULT_SCOPES.split(","),
+        "token_name": M.any,
+    }
+    mock_check_token_authorization.assert_called_with(
+        uri=MOCK_RESPONSE["token_uri"], device_code=MOCK_RESPONSE["device_code"]
+    )
+
+    config = dvc.config.load_one("global")
+    assert config["studio"]["token"] == "isat_access_token"
+    assert config["studio"]["url"] == STUDIO_URL
+
+
+@mock.patch("dvc_studio_client.auth.check_token_authorization")
+@mock.patch("dvc_studio_client.auth.start_device_login")
+def test_studio_login_arguments(
+    mock_start_device_login, mock_check_token_authorization
+):
+    mock_start_device_login.return_value = MOCK_RESPONSE
+    mock_check_token_authorization.return_value = "isat_access_token"
+
+    assert (
+        main(
+            [
+                "studio",
+                "login",
+                "--name",
+                "token_name",
+                "--hostname",
+                "https://example.com",
+                "--scopes",
+                "live",
+            ]
+        )
+        == 0
+    )
+
+    mock_start_device_login.assert_called_with(
+        client_name="dvc",
+        base_url="https://example.com",
+        token_name="token_name",
+        scopes=["live"],
+    )
+    mock_check_token_authorization.assert_called()
+
+
+@mock.patch("dvc_studio_client.auth.check_token_authorization")
+@mock.patch("dvc_studio_client.auth.start_device_login")
+@mock.patch("webbrowser.open")
+def test_studio_device_code(
+    mock_webbrowser_open, mock_start_device_login, mock_check_token_authorization
+):
+    mock_start_device_login.return_value = MOCK_RESPONSE
+    mock_check_token_authorization.return_value = "isat_access_token"
+
+    assert main(["studio", "login", "--use-device-code"]) == 0
+
+    mock_webbrowser_open.assert_not_called()
+
+
+def test_studio_logout(dvc):
+    with dvc.config.edit("global") as conf:
+        conf["studio"]["token"] = "isat_access_token"
+
+    assert main(["studio", "logout"]) == 0
+    config = dvc.config.load_one("global")
+    assert "token" not in config["studio"]
+
+    assert main(["studio", "logout"]) == 1
+
+
+@mock.patch("dvc.ui.ui.write")
+def test_studio_token(mock_write, dvc):
+    with dvc.config.edit("global") as conf:
+        conf["studio"]["token"] = "isat_access_token"
+
+    assert main(["studio", "token"]) == 0
+    mock_write.assert_called_with("isat_access_token")
+
+    with dvc.config.edit("global") as conf:
+        del conf["studio"]["token"]
+
+    assert main(["studio", "token"]) == 1
diff --git a/tests/unit/utils/test_studio.py b/tests/unit/utils/test_studio.py
index 68dcfed77d..7c0a99f85c 100644
--- a/tests/unit/utils/test_studio.py
+++ b/tests/unit/utils/test_studio.py
@@ -13,7 +13,6 @@
 
 CONFIG = {"offline": True, "repo_url": "repo_url", "token": "token", "url": "url"}
 
-
 ENV = {
     DVC_STUDIO_OFFLINE: True,
     DVC_STUDIO_REPO_URL: "repo_url",


Code review comments:
diff_hunk: @@ -66,7 +66,7 @@ def notify_refs(
     try:
         r = post("webhook/dvc", token, data, base_url=base_url)
     except requests.RequestException as e:
-        logger.trace("", exc_info=True)
+        logger.trace("", exc_info=True)  # type: ignore[attr-defined]
path: dvc/utils/studio.py
text: @user1:
The suppression is probably not needed now.

diff_hunk: @@ -13,7 +13,6 @@
 
 CONFIG = {"offline": True, "repo_url": "repo_url", "token": "token", "url": "url"}
 
-
path: tests/unit/utils/test_studio.py
text: @user1:
Looks like an unrelated stray change

diff_hunk: @@ -0,0 +1,105 @@
+from requests import Response
+
+from dvc.cli import main
+from dvc.commands.studio import DEFAULT_SCOPES
+from dvc.utils.studio import STUDIO_URL
+from tests.unit.command.test_studio import MOCK_RESPONSE
+
+
+def test_auth_expired(mocker, M):
path: tests/func/test_studio.py
text: @user1:
@author, also, please move the most of these tests to `dvc-studio-client`.
And here let's mock the studio-client's API and only test for saving config and command related stuff here.

diff_hunk: @@ -0,0 +1,172 @@
+import argparse
+import os
+
+from funcy import get_in
+
+from dvc.cli.utils import append_doc_link, fix_subparsers
+from dvc.commands.config import CmdConfig
+from dvc.log import logger
+
+logger = logger.getChild(__name__)
+
+DEFAULT_SCOPES = "live,dvc_experiment,view_url,dql,download_model"
+
+
+class CmdStudioLogin(CmdConfig):
+    def run(self):
+        from dvc_studio_client.auth import StudioAuthError, initiate_authorization
+
+        from dvc.env import DVC_STUDIO_URL
+        from dvc.ui import ui
+        from dvc.utils.studio import STUDIO_URL
+
+        name = self.args.name
+        hostname = self.args.hostname or os.environ.get(DVC_STUDIO_URL) or STUDIO_URL
+        scopes = self.args.scopes or DEFAULT_SCOPES
+
+        try:
+            token_name, access_token = initiate_authorization(
+                name=name,
+                hostname=hostname,
+                scopes=scopes,
+                use_device_code=self.args.use_device_code,
+            )
+        except StudioAuthError as e:
+            ui.error_write(str(e))
+            return 1
+
+        self.save_config(hostname, access_token)
+        ui.write(
+            f"Authentication successful. The token will be "
path: dvc/commands/studio.py
text: @user1:
```suggestion
            "Authentication successful. The token will be "
```

diff_hunk: @@ -0,0 +1,172 @@
+import argparse
+import os
+
+from funcy import get_in
+
+from dvc.cli.utils import append_doc_link, fix_subparsers
+from dvc.commands.config import CmdConfig
+from dvc.log import logger
+
+logger = logger.getChild(__name__)
+
+DEFAULT_SCOPES = "live,dvc_experiment,view_url,dql,download_model"
+
+
+class CmdStudioLogin(CmdConfig):
+    def run(self):
+        from dvc_studio_client.auth import StudioAuthError, initiate_authorization
+
+        from dvc.env import DVC_STUDIO_URL
+        from dvc.ui import ui
+        from dvc.utils.studio import STUDIO_URL
+
+        name = self.args.name
+        hostname = self.args.hostname or os.environ.get(DVC_STUDIO_URL) or STUDIO_URL
+        scopes = self.args.scopes or DEFAULT_SCOPES
+
+        try:
+            token_name, access_token = initiate_authorization(
path: dvc/commands/studio.py
text: @user1:
On studio-client side, let's rename this to a more meaningful name. `get_access_token()` perhaps? Or if you have something better.