Repository: dwavesystems/dwave-cloud-client

Issue:
Implement CLI support for auth (`dwave auth` subcommand)
Add `auth` subcommand to `dwave` CLI.

Add the following subcommands to `dwave auth`:
- `login` - initiate the OAuth flow. Use localhost-bound redirect URL, but support `--oob` (manual `code` & `state` entry)
- ~`list` - list existing identities/tokens~
- `get` - print access/refresh/id token
- `refresh` - refresh access/id token
- ~`revoke` - revoke a token~ (API not available)
- ~`ensure-valid` - refresh token only if needed~

Discussion:

Patch:
diff --git a/dwave/cloud/api/models.py b/dwave/cloud/api/models.py
index c3faf56..3a7b13d 100644
--- a/dwave/cloud/api/models.py
+++ b/dwave/cloud/api/models.py
@@ -175,7 +175,7 @@ class _LeapProjectsResponse(BaseModel):
 
 # LeapAPI / account / token response
 class _LeapTokenWrapper(BaseModel):
-    token: str
+    token: Optional[str]
 
 class _LeapProjectTokenResponse(BaseModel):
     data: _LeapTokenWrapper
diff --git a/dwave/cloud/api/resources.py b/dwave/cloud/api/resources.py
index c9fc933..487fcb0 100644
--- a/dwave/cloud/api/resources.py
+++ b/dwave/cloud/api/resources.py
@@ -298,7 +298,7 @@ class LeapAccount(ResourceBase):
 
     def get_project_token(self, *,
                           project: Optional[models.LeapProject] = None,
-                          project_id: Optional[str] = None) -> str:
+                          project_id: Optional[str] = None) -> Optional[str]:
         """Retrieve SAPI token for a given Leap project."""
         if project is not None:
             project_id = project.id
diff --git a/dwave/cloud/auth/flows.py b/dwave/cloud/auth/flows.py
index 90b6b7d..942f206 100644
--- a/dwave/cloud/auth/flows.py
+++ b/dwave/cloud/auth/flows.py
@@ -15,21 +15,25 @@
 from __future__ import annotations
 
 import logging
+import time
 import webbrowser
 from operator import sub
 from typing import Any, Callable, Dict, Optional, Union, Sequence
 from urllib.parse import urljoin, parse_qsl
 
-from authlib.integrations.requests_client import OAuth2Session
+import click
+from authlib.integrations.requests_client import OAuth2Session, OAuthError
+from authlib.oauth2.rfc6749 import OAuth2Token
 from authlib.common.security import generate_token
 
 from dwave.cloud.auth.config import OCEAN_SDK_CLIENT_ID, OCEAN_SDK_SCOPES
+from dwave.cloud.auth.creds import Credentials
 from dwave.cloud.auth.server import SingleRequestAppServer, RequestCaptureApp
 from dwave.cloud.config.models import ClientConfig
 from dwave.cloud.regions import resolve_endpoints
 from dwave.cloud.utils import pretty_argvalues
 
-__all__ = ['AuthFlow', 'LeapAuthFlow']
+__all__ = ['AuthFlow', 'LeapAuthFlow', 'OAuthError']
 
 logger = logging.getLogger(__name__)
 
@@ -54,6 +58,12 @@ class AuthFlow:
             Configuration options for the low-level ``requests.Session`` used
             for all OAuth 2 requests. Supported options are: ``cert``,
             ``cookies``, ``headers``, ``proxies``, ``timeout``, ``verify``.
+        leap_api_endpoint:
+            Leap API endpoint, optional. Used to scope the token in credentials
+            store.
+        creds:
+            :class:`~dwave.cloud.auth.creds.Credentials` store used to persist
+            the token fetched.
 
     .. _OAuth 2.0 Authorization Code:
         https://datatracker.ietf.org/doc/html/rfc6749#section-4.1
@@ -68,11 +78,15 @@ class AuthFlow:
                  authorization_endpoint: str,
                  token_endpoint: str,
                  session_config: Optional[Dict[str, Any]] = None,
+                 leap_api_endpoint: Optional[str] = None,
+                 creds: Optional[Credentials] = None
                  ):
         self.client_id = client_id
         self.scopes = ' '.join(scopes)
         self.authorization_endpoint = authorization_endpoint
         self.token_endpoint = token_endpoint
+        self.leap_api_endpoint = leap_api_endpoint
+        self.creds = creds
 
         self.session = OAuth2Session(
             client_id=client_id, scope=scopes, redirect_uri=redirect_uri,
@@ -84,6 +98,8 @@ class AuthFlow:
         if session_config is not None:
             self.update_session(session_config)
 
+        self.token = self._load_token_from_creds()
+
         logger.debug(f"{type(self).__name__} initialized with: {pretty_argvalues()}")
 
     def update_session(self, config: Dict[str, Any]) -> None:
@@ -99,21 +115,41 @@ class AuthFlow:
             if key in config:
                 setattr(self.session, key, config[key])
 
+    def _load_token_from_creds(self) -> Optional[dict]:
+        """Update session token with value from creds."""
+        if not isinstance(self.creds, Credentials) or not self.leap_api_endpoint:
+            return
+
+        token = self.creds.get(self.leap_api_endpoint)
+        logger.debug(f"{type(self).__name__} loaded token {token!r} from {self.creds}")
+
+        return token
+
+    def _save_token_to_creds(self, token: Union[OAuth2Token, dict]):
+        """Persist session token to creds file."""
+        if not isinstance(self.creds, Credentials) or not self.leap_api_endpoint:
+            return
+
+        if token:
+            self.creds[self.leap_api_endpoint] = token
+            logger.debug(f"{type(self).__name__} saved token {token!r} to {self.creds}")
+
     @property
-    def redirect_uri(self):
+    def redirect_uri(self) -> str:
         return self.session.redirect_uri
 
     @redirect_uri.setter
-    def redirect_uri(self, value):
+    def redirect_uri(self, value: str):
         self.session.redirect_uri = value
 
     @property
-    def token(self):
+    def token(self) -> OAuth2Token:
         return self.session.token
 
     @token.setter
-    def token(self, value):
+    def token(self, value: Union[OAuth2Token, dict]):
         self.session.token = value
+        self._save_token_to_creds(value)
 
     def get_authorization_url(self) -> str:
         self.state = generate_token(30)
@@ -155,13 +191,26 @@ class AuthFlow:
             **kwargs)
 
         logger.debug(f"{type(self).__name__}.fetch_token() = {token!r}")
+        self._save_token_to_creds(token)
+
         return token
 
     def refresh_token(self):
-        return self.session.refresh_token(url=self.token_endpoint)
+        token = self.session.refresh_token(url=self.token_endpoint)
+        self._save_token_to_creds(token)
+        return token
 
     def ensure_active_token(self):
-        return self.session.ensure_active_token(token=self.session.token)
+        is_active = self.session.ensure_active_token(token=self.session.token)
+        self._save_token_to_creds(self.token)
+        return is_active
+
+    def token_expires_soon(self, within: int = 60) -> Optional[bool]:
+        """Is the token expired, or expires soon (within the next ``within`` seconds)?"""
+        expires_at = self.token.get('expires_at')
+        if not expires_at:
+            return None
+        return (expires_at - within) < time.time()
 
 
 class LeapAuthFlow(AuthFlow):
@@ -184,7 +233,7 @@ class LeapAuthFlow(AuthFlow):
     _OOB_REDIRECT_URI = 'urn:ietf:wg:oauth:2.0:oob'
 
     _VISIT_AUTH_MSG = 'Please visit the following URL to authorize Ocean: '
-    _INPUT_CODE_MSG = 'Authorization code: '
+    _INPUT_CODE_MSG = 'Authorization code'
 
     _REDIRECT_HOST = '127.0.0.1'
     _REDIRECT_PORT_RANGE = (36000, 36050)
@@ -222,6 +271,7 @@ class LeapAuthFlow(AuthFlow):
                      f"config={config!r}, **kwargs={kwargs!r})")
 
         config = resolve_endpoints(config, inplace=False)
+        creds = Credentials()
 
         authorization_endpoint = cls._infer_auth_endpoint(config.leap_api_endpoint)
         token_endpoint = cls._infer_token_endpoint(config.leap_api_endpoint)
@@ -235,15 +285,22 @@ class LeapAuthFlow(AuthFlow):
             timeout=config.request_timeout,
             verify=not config.permissive_ssl)
 
-        return cls(
+        flow = cls(
             client_id=kwargs.pop('client_id', config.leap_client_id or OCEAN_SDK_CLIENT_ID),
             scopes=kwargs.pop('scopes', OCEAN_SDK_SCOPES),
             redirect_uri=kwargs.pop('redirect_uri', cls._OOB_REDIRECT_URI),
             authorization_endpoint=authorization_endpoint,
             token_endpoint=token_endpoint,
-            session_config=session_config)
+            session_config=session_config,
+            leap_api_endpoint=config.leap_api_endpoint,
+            creds=creds)
+
+        # save full config if needed later
+        flow.config = config
 
-    def run_oob_flow(self):
+        return flow
+
+    def run_oob_flow(self, *, open_browser: Union[bool,Callable] = False):
         """Run OAuth 2.0 code exchange (out-of-band flow.) 
         
         Runs the OAuth 2.0 Authorization Code exchange flow using the out-of-band code 
@@ -257,9 +314,16 @@ class LeapAuthFlow(AuthFlow):
         self.redirect_uri = self._OOB_REDIRECT_URI
 
         url = self.get_authorization_url()
-        print(self._VISIT_AUTH_MSG, url)
+        click.echo(click.style(self._VISIT_AUTH_MSG, bold=True), nl=False)
+        click.echo(click.style(url, underline=True))
+        click.echo()
+        if open_browser:
+            if callable(open_browser):
+                open_browser(url)
+            else:
+                webbrowser.open(url)
 
-        code = input(self._INPUT_CODE_MSG)
+        code = click.prompt(click.style(self._INPUT_CODE_MSG, bold=True))
         return self.fetch_token(code=code)
 
     def run_redirect_flow(self, *, open_browser: Union[bool,Callable] = False,
@@ -300,7 +364,8 @@ class LeapAuthFlow(AuthFlow):
         self.redirect_uri = srv.root_url
 
         url = self.get_authorization_url()
-        print(self._VISIT_AUTH_MSG, url)
+        click.echo(click.style(self._VISIT_AUTH_MSG, bold=True), nl=False)
+        click.echo(click.style(url, underline=True))
         if open_browser:
             if callable(open_browser):
                 open_browser(url)
diff --git a/dwave/cloud/cli.py b/dwave/cloud/cli.py
index c13dc66..af05859 100644
--- a/dwave/cloud/cli.py
+++ b/dwave/cloud/cli.py
@@ -18,13 +18,12 @@ import ast
 import json
 import subprocess
 import pkg_resources
-
 from collections.abc import Sequence
+from configparser import ConfigParser
+from datetime import datetime
 from functools import wraps, partial
 from timeit import default_timer as timer
-
-from typing import Dict
-from configparser import ConfigParser
+from typing import Dict, Optional
 
 import click
 import requests.exceptions
@@ -36,7 +35,7 @@ from dwave.cloud.solver import StructuredSolver, BaseUnstructuredSolver
 from dwave.cloud.utils import (
     default_text_input, generate_random_ising_problem,
     datetime_to_timestamp, utcnow, strtrunc, CLIError, set_loglevel,
-    get_contrib_packages, user_agent)
+    get_contrib_packages, user_agent, epochnow)
 from dwave.cloud.coders import bqm_as_file
 from dwave.cloud.package_info import __title__, __version__
 from dwave.cloud.exceptions import (
@@ -45,9 +44,10 @@ from dwave.cloud.exceptions import (
     RequestTimeout, PollingTimeout)
 from dwave.cloud.config import (
     load_profile_from_files, load_config_from_files, load_config, get_default_config,
-    get_configfile_path, get_default_configfile_path,
-    get_configfile_paths)
+    get_configfile_path, get_default_configfile_path, get_configfile_paths)
+from dwave.cloud.config.models import validate_config_v1
 from dwave.cloud.regions import get_regions
+from dwave.cloud.auth.flows import LeapAuthFlow, OAuthError
 
 
 # show defaults for all click options when printing --help
@@ -114,6 +114,15 @@ def endpoint_options(fn):
     return fn
 
 
+def json_output(fn):
+    """Decorate `fn` with `--json` option."""
+
+    fn = click.option('--json', 'json_output', default=False, is_flag=True,
+                      help='JSON output')(fn)
+
+    return fn
+
+
 @click.group()
 @click.version_option(prog_name=__title__, version=__version__)
 @click.option('--debug', is_flag=True, callback=enable_logging,
@@ -177,11 +186,14 @@ def inspect(config_file, profile):
 @config_file_options(exists=False)
 @click.option('--full', 'ask_full', is_flag=True,
               help='Configure non-essential options (such as endpoint and solver).')
-def create(config_file, profile, ask_full):
+@click.option('--auto', 'auto_token', is_flag=True, default=False,
+              help='Pull token from Leap API, if "dwave auth login" has been run.')
+def create(*, config_file, profile, ask_full, auto_token):
     """Create or update cloud client configuration file."""
 
     try:
-        _config_create(config_file, profile, ask_full)
+        _config_create(config_file=config_file, profile=profile,
+                       ask_full=ask_full, auto_token=auto_token)
     except CLIError as error:
         click.echo(f"Error: {error!s} (code: {error.code})")
         sys.exit(error.code)
@@ -229,7 +241,26 @@ def _write_config(config: ConfigParser, config_file: str):
     except Exception as e:
         raise CLIError(f"Error writing to configuration file: {e!r}", code=2)
 
-def _config_create(config_file, profile, ask_full=False):
+def _fetch_sapi_token(config_file: str, profile: str) -> Optional[str]:
+    """Fetch SAPI token from Leap API for a currently active project."""
+
+    config = validate_config_v1(load_config(config_file=config_file, profile=profile))
+    flow = LeapAuthFlow.from_config_model(config)
+
+    if not flow.token or 'access_token' not in flow.token:
+        click.echo('Leap API access token not found.')
+        return
+
+    if flow.token_expires_soon(within=60):
+        click.echo("Refreshing Leap access token since it expired.")
+        flow.refresh_token()
+
+    account = api.LeapAccount.from_config(config=flow.config,
+                                          token=flow.token.get('access_token'))
+    active_project = account.get_active_project()
+    return account.get_project_token(project=active_project)
+
+def _config_create(config_file, profile, ask_full=False, auto_token=False):
     """Full/simplified dwave create flows."""
 
     if ask_full:
@@ -245,13 +276,13 @@ def _config_create(config_file, profile, ask_full=False):
         prompts = dict(
             region=dict(prompt="Solver API region", choices=region_choices),
             endpoint=dict(prompt="Solver API endpoint URL (overwrites 'region')"),
-            token=dict(prompt="Authentication token"),
+            token=dict(prompt="Solver API token"),
             client=dict(prompt="Client class", choices='base qpu sw hybrid'.split()),
             solver=dict(prompt="Solver"))
 
     else:
         prompts = dict(
-            token=dict(prompt="Authentication token"))
+            token=dict(prompt="Solver API token"))
 
         click.echo("Using the simplified configuration flow.\n"
                    "Try 'dwave config create --full' for more options.\n")
@@ -292,6 +323,18 @@ def _config_create(config_file, profile, ask_full=False):
     if profile != default_section and not config.has_section(profile):
         config.add_section(profile)
 
+    # set using Leap API
+    try:
+        sapi_token = _fetch_sapi_token(config_file, profile) if auto_token else None
+    except Exception as error:
+        click.echo(f"Failed to fetch SAPI token from Leap API ({error!s}).")
+        sapi_token = None
+
+    if sapi_token:
+        del prompts['token']
+        config.set(profile, 'token', sapi_token)
+        click.echo("Using SAPI token fetched from Leap API.")
+
     _input_config_variables(config, profile, prompts)
 
     _write_config(config, config_file)
@@ -406,8 +449,12 @@ def standardized_output(fn):
         except CLIError as error:
             output("Error: {error} (code: {code})", error=str(error), code=error.code)
             sys.exit(error.code)
+        except OAuthError as error:
+            output("Auth error: {error}", error=str(error))
+            output('Re-authorize with "dwave auth login" and retry.')
+            sys.exit(127)
         except Exception as error:
-            output("Unhandled error: {error}", error=repr(error))
+            output("Unhandled error: {error}", error=str(error))
             sys.exit(127)
         finally:
             flush()
@@ -896,3 +943,160 @@ def setup(install_all, ask_full, verbose):
 
     click.echo("Creating the D-Wave configuration file.")
     return _config_create(config_file=None, profile=None, ask_full=ask_full)
+
+
+@cli.group()
+def auth():
+    """Authorize Leap access and fetch Leap/Solver API tokens."""
+
+@auth.command()
+@config_file_options()
+@click.option('--oob', is_flag=True,
+              help='Run OAuth 2.0 Authorization Code flow out-of-band, '
+                   'without the use of locally hosted redirect URL.')
+@standardized_output
+def login(*, config_file, profile, oob, output):
+    """Authorize Ocean to access Leap API on user's behalf."""
+
+    config = validate_config_v1(load_config(config_file=config_file, profile=profile))
+    flow = LeapAuthFlow.from_config_model(config)
+
+    if oob:
+        # XXX: until the standard OOB URI is supported on Leap
+        flow._OOB_REDIRECT_URI = 'oob'
+        flow.run_oob_flow(open_browser=True)
+    else:
+        flow.run_redirect_flow(open_browser=True)
+
+    click.echo('\nAuthorization successfully completed. '
+               'You can now use "dwave auth get" to fetch a token.')
+
+
+@auth.command()
+@config_file_options()
+@click.argument('token_type', default='access-token',
+                type=click.Choice(['access-token', 'refresh-token', 'id-token']))
+@click.option('--json', 'json_output', default=False, is_flag=True,
+              help='JSON output')
+@standardized_output
+def get(*, config_file, profile, token_type, json_output, output):
+    """Fetch Leap API token."""
+
+    config = validate_config_v1(load_config(config_file=config_file, profile=profile))
+    flow = LeapAuthFlow.from_config_model(config)
+
+    token_name = token_type.replace('-', '_')
+    token_value = flow.token.get(token_name)
+    if not token_value:
+        raise CLIError('Token not found. Please run "dwave auth login".', code=100)
+
+    output("Token: {token}", token=token_value)
+
+    if token_type == 'access-token':
+        expires_at = flow.token.get('expires_at')
+        if not expires_at:
+            return
+        expires_at = int(expires_at)
+        expired = expires_at < epochnow()
+
+        output("Expires at: {expires_at_iso}Z (timestamp={expires_at}) ({is_valid})",
+            expires_at_iso=datetime.utcfromtimestamp(expires_at).isoformat(),
+            expires_at=expires_at,
+            is_valid="expired" if expired else "valid")
+
+        if expired:
+            output('\nTo refresh the token, please run "dwave auth refresh".')
+
+
+@auth.command()
+@config_file_options()
+@standardized_output
+def refresh(*, config_file, profile, output):
+    """Refresh Leap API access token."""
+
+    config = validate_config_v1(load_config(config_file=config_file, profile=profile))
+    flow = LeapAuthFlow.from_config_model(config)
+
+    # check we have a token
+    if not flow.token or 'refresh_token' not in flow.token:
+        raise CLIError('Refresh token not found. Please run "dwave auth login".', code=100)
+
+    # refresh
+    flow.refresh_token()
+
+    output('Access and refresh tokens successfully refreshed. '
+           'You can now use "dwave auth get" to view them.')
+
+
+@cli.group()
+def leap():
+    """Interact with Leap API."""
+
+
+@leap.group()
+def project():
+    """Leap projects."""
+
+
+@project.command(name='ls')
+@config_file_options()
+@json_output
+@standardized_output
+def list_leap_projects(*, config_file, profile, json_output, output):
+    """List available Leap projects."""
+
+    config = validate_config_v1(load_config(config_file=config_file, profile=profile))
+    flow = LeapAuthFlow.from_config_model(config)
+
+    if not flow.token or 'access_token' not in flow.token:
+        raise CLIError('Leap API access token not found. Please run "dwave auth login".', code=100)
+
+    if flow.token_expires_soon(within=60):
+        output("Access token expired (or expires soon), refreshing it.")
+        flow.refresh_token()
+
+    account = api.LeapAccount.from_config(config=flow.config,
+                                          token=flow.token.get('access_token'))
+    projects = account.list_projects()
+    output("Leap projects:", projects=[p.model_dump() for p in projects])
+    for project in projects:
+        output(f" - {project.name} (code={project.code!r}, id={project.id})")
+
+
+@project.command(name='token')
+@config_file_options()
+@click.option('--project', 'project_slug', required=True, type=str,
+              help='Leap project ID, name or code.')
+@json_output
+@standardized_output
+def leap_project_token(*, config_file, profile, project_slug, json_output, output):
+    """Get Solver API token for a selected Leap project."""
+
+    config = validate_config_v1(load_config(config_file=config_file, profile=profile))
+    flow = LeapAuthFlow.from_config_model(config)
+
+    if not flow.token or 'access_token' not in flow.token:
+        raise CLIError('Leap API access token not found. Please run "dwave auth login".', code=100)
+
+    if flow.token_expires_soon(within=120):
+        output("Access token expired (or expires soon), refreshing it.")
+        flow.refresh_token()
+
+    account = api.LeapAccount.from_config(config=flow.config,
+                                          token=flow.token.get('access_token'))
+    projects = account.list_projects()
+
+    # find project id
+    needle = str(project_slug).strip().lower()
+    project = [p for p in projects if needle in (str(p.id), p.name.lower(), p.code.lower())]
+
+    if not project:
+        raise CLIError(f'Project with {project_slug!r} ID, name or code not found. '
+                       'Please run "dwave leap project ls" to list available projects.', code=101)
+
+    # get token
+    project = project[0]
+    token = account.get_project_token(project=project)
+
+    output(f"Solver API token for project {project.name} ({project.code}) is {token}.",
+           token=token)
diff --git a/releasenotes/notes/add-auth-cli-a7c41932f5b4bd26.yaml b/releasenotes/notes/add-auth-cli-a7c41932f5b4bd26.yaml
new file mode 100644
index 0000000..e8e92b5
--- /dev/null
+++ b/releasenotes/notes/add-auth-cli-a7c41932f5b4bd26.yaml
@@ -0,0 +1,13 @@
+---
+features:
+  - |
+    Add Leap authorization-related group of CLI commands under ``dwave auth``:
+    ``login``, ``get <token-type>``,  ``refresh``.
+    See `#565 <https://github.com/dwavesystems/dwave-cloud-client/issues/565>`_.
+  - |
+    Add Leap project CLI commands under new ``dwave leap project`` group:
+    ``ls``, ``token``.
+  - |
+    Add ``--auto`` option to ``dwave config create`` to attempt SAPI token
+    fetch from Leap API on configuration file create (if access already
+    authorized with ``dwave auth login``).


Test patch:
diff --git a/tests/auth/test_flows.py b/tests/auth/test_flows.py
index c33ced6..1831331 100644
--- a/tests/auth/test_flows.py
+++ b/tests/auth/test_flows.py
@@ -12,6 +12,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+import time
 import threading
 import unittest
 from functools import partial
@@ -23,6 +24,7 @@ import requests_mock
 
 from dwave.cloud.auth.flows import AuthFlow, LeapAuthFlow
 from dwave.cloud.auth.config import OCEAN_SDK_CLIENT_ID, OCEAN_SDK_SCOPES
+from dwave.cloud.auth.creds import Credentials
 from dwave.cloud.config import ClientConfig
 from dwave.cloud.api.constants import DEFAULT_LEAP_API_ENDPOINT
 
@@ -36,13 +38,17 @@ class TestAuthFlow(unittest.TestCase):
         self.authorization_endpoint = 'https://example.com/authorize'
         self.token_endpoint = 'https://example.com/token'
         self.token = dict(access_token='123', refresh_token='456', id_token='789')
+        self.creds = Credentials(create=False)
+        self.leap_api_endpoint = 'https://example.com/leap/api'
 
         self.test_args = dict(
             client_id=self.client_id,
             scopes=self.scopes,
             redirect_uri=self.redirect_uri_oob,
             authorization_endpoint=self.authorization_endpoint,
-            token_endpoint=self.token_endpoint)
+            token_endpoint=self.token_endpoint,
+            leap_api_endpoint=self.leap_api_endpoint,
+            creds=self.creds)
 
     def test_auth_url(self):
         flow = AuthFlow(**self.test_args)
@@ -86,6 +92,9 @@ class TestAuthFlow(unittest.TestCase):
         m.post(requests_mock.ANY, status_code=404)
         m.post(self.token_endpoint, additional_matcher=post_body_matcher, json=self.token)
 
+        # reset creds
+        self.creds.clear()
+
         # verify token fetch flow
         flow = AuthFlow(**self.test_args)
 
@@ -95,24 +104,37 @@ class TestAuthFlow(unittest.TestCase):
         # verify token proxy to oauth2 session
         self.assertEqual(flow.token, self.token)
 
+        # verify token saved to creds
+        self.assertEqual(flow.creds[flow.leap_api_endpoint], self.token)
+
     def test_token_setter(self):
         flow = AuthFlow(**self.test_args)
 
         self.assertIsNone(flow.session.token)
 
+        self.creds.clear()
+
         flow.token = self.token
 
         self.assertIsNotNone(flow.session.token)
         self.assertEqual(flow.session.token['access_token'], self.token['access_token'])
 
+        # verify token is persisted
+        self.assertEqual(self.creds[self.leap_api_endpoint], self.token)
+
     def test_refresh_token(self):
         flow = AuthFlow(**self.test_args)
+        self.creds.clear()
 
-        with mock.patch.object(flow.session, 'refresh_token') as m:
+        with mock.patch.object(flow.session, 'refresh_token',
+                               return_value=self.token) as m:
             flow.refresh_token()
 
         m.assert_called_once_with(url=flow.token_endpoint)
 
+        # verify token is persisted
+        self.assertEqual(self.creds[self.leap_api_endpoint], self.token)
+
     def test_ensure_active_token(self):
         flow = AuthFlow(**self.test_args)
         flow.token = self.token
@@ -146,6 +168,16 @@ class TestAuthFlow(unittest.TestCase):
             flow.update_session(config)
             verify(flow.session, config)
 
+    def test_token_expires_soon(self):
+        flow = AuthFlow(**self.test_args)
+        now = time.time()
+
+        with mock.patch.object(flow.session.token_auth, 'token',
+                               dict(expires_at=now + 59)):
+            self.assertTrue(flow.token_expires_soon())
+            self.assertTrue(flow.token_expires_soon(within=60))
+            self.assertFalse(flow.token_expires_soon(within=0))
+
 
 class TestLeapAuthFlow(unittest.TestCase):
 
@@ -170,10 +202,12 @@ class TestLeapAuthFlow(unittest.TestCase):
         # Leap-specific:
         self.assertTrue(flow.authorization_endpoint.endswith('authorize'))
         self.assertTrue(flow.token_endpoint.endswith('token'))
+        self.assertEqual(flow.leap_api_endpoint, config.leap_api_endpoint)
 
         self.assertEqual(flow.client_id, OCEAN_SDK_CLIENT_ID)
         self.assertEqual(flow.scopes, ' '.join(OCEAN_SDK_SCOPES))
         self.assertEqual(flow.redirect_uri, LeapAuthFlow._OOB_REDIRECT_URI)
+        self.assertIsNotNone(flow.creds)
 
     def test_from_minimal_config_with_overrides(self):
         config = ClientConfig(leap_api_endpoint='https://example.com/leap')
@@ -210,19 +244,19 @@ class TestLeapAuthFlow(unittest.TestCase):
 
 class TestLeapAuthFlowRunners(unittest.TestCase):
 
-    @mock.patch('builtins.print', return_value=None)
+    @mock.patch('click.echo', return_value=None)
     def test_oob(self, m):
         config = ClientConfig(leap_api_endpoint='https://example.com/leap')
         flow = LeapAuthFlow.from_config_model(config)
 
         mock_code = '1234'
 
-        with mock.patch('builtins.input', return_value=mock_code):
+        with mock.patch('click.prompt', return_value=mock_code):
             with mock.patch.object(flow, 'fetch_token') as fetch_token:
                 flow.run_oob_flow()
                 fetch_token.assert_called_once_with(code=mock_code)
 
-    @mock.patch('builtins.print', return_value=None)
+    @mock.patch('click.echo', return_value=None)
     def test_redirect(self, m):
         config = ClientConfig(leap_api_endpoint='https://example.com/leap')
         flow = LeapAuthFlow.from_config_model(config)
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 598ad30..899b5e2 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -22,7 +22,9 @@ from parameterized import parameterized
 
 from dwave.cloud.cli import cli
 from dwave.cloud.config import load_config
+from dwave.cloud.config.models import validate_config_v1
 from dwave.cloud.testing import isolated_environ
+from dwave.cloud.api.models import LeapProject
 
 from tests import config, test_config_path, test_config_profile
 from tests.test_mock_solver_loading import solver_object
@@ -429,6 +431,146 @@ class TestCli(unittest.TestCase):
             self.assertIn(key, result.output)
 
 
+class TestAuthCli(unittest.TestCase):
+
+    @mock.patch('dwave.cloud.auth.flows.LeapAuthFlow.from_config_model')
+    def test_login(self, flow_factory):
+        flow = flow_factory.return_value
+
+        with self.subTest('dwave auth login'):
+            flow.reset_mock()
+            runner = CliRunner(mix_stderr=False)
+            with runner.isolated_filesystem():
+                result = runner.invoke(cli, ['auth', 'login'])
+
+            flow.run_redirect_flow.assert_called_once()
+            self.assertEqual(result.exit_code, 0)
+
+        with self.subTest('dwave auth login --oob'):
+            flow.reset_mock()
+            runner = CliRunner(mix_stderr=False)
+            with runner.isolated_filesystem():
+                result = runner.invoke(cli, ['auth', 'login', '--oob'])
+
+            flow.run_oob_flow.assert_called_once()
+            self.assertEqual(result.exit_code, 0)
+
+        with self.subTest('dwave auth login --config-file <file> --profile <profile>'):
+            config_file = 'dwave.conf'
+            profile = 'profile'
+            leap_api_endpoint = 'https://example.com/leap/api'
+            config_lines = [f'[{profile}]', f'leap_api_endpoint = {leap_api_endpoint}']
+
+            flow_factory.reset_mock()
+            runner = CliRunner(mix_stderr=False)
+            with runner.isolated_filesystem():
+                # create config
+                with open(config_file, 'w') as fp:
+                    fp.write('\n'.join(config_lines))
+
+                result = runner.invoke(cli, ['auth', 'login',
+                                             '--config-file', config_file,
+                                             '--profile', profile])
+
+            config = validate_config_v1(dict(leap_api_endpoint=leap_api_endpoint))
+            flow_factory.assert_called_with(config)
+            self.assertEqual(result.exit_code, 0)
+
+    @mock.patch('dwave.cloud.auth.flows.LeapAuthFlow.from_config_model')
+    def test_get(self, flow_factory):
+        flow = flow_factory.return_value
+
+        with self.subTest('dwave auth get access-token'):
+            flow.reset_mock()
+            runner = CliRunner(mix_stderr=False)
+            with runner.isolated_filesystem():
+                result = runner.invoke(cli, ['auth', 'get', 'access-token'])
+
+            flow.token.get.assert_has_calls(
+                [mock.call('access_token'), mock.call('expires_at')], any_order=True)
+            self.assertIn('Token:', result.output)
+
+            self.assertEqual(result.exit_code, 0)
+
+        with self.subTest('dwave auth get refresh-token'):
+            flow.reset_mock()
+            runner = CliRunner(mix_stderr=False)
+            with runner.isolated_filesystem():
+                result = runner.invoke(cli, ['auth', 'get', 'refresh-token'])
+
+            flow.token.get.assert_called_with('refresh_token')
+            self.assertIn('Token:', result.output)
+
+            self.assertEqual(result.exit_code, 0)
+
+    @mock.patch('dwave.cloud.auth.flows.LeapAuthFlow.from_config_model')
+    def test_refresh(self, flow_factory):
+        flow = flow_factory.return_value
+        type(flow).token = mock.PropertyMock(return_value=dict(refresh_token='123'))
+
+        with self.subTest('dwave auth refresh'):
+            runner = CliRunner(mix_stderr=False)
+            with runner.isolated_filesystem():
+                result = runner.invoke(cli, ['auth', 'refresh'])
+
+            self.assertEqual(result.exit_code, 0)
+            flow.refresh_token.assert_called_once()
+
+        flow.reset_mock()
+        type(flow).token = mock.PropertyMock(return_value=dict())
+
+        with self.subTest('dwave auth refresh (no token)'):
+            runner = CliRunner(mix_stderr=False)
+            with runner.isolated_filesystem():
+                result = runner.invoke(cli, ['auth', 'refresh'])
+
+            self.assertEqual(result.exit_code, 100)
+
+    @mock.patch('dwave.cloud.api.resources.LeapAccount.from_config')
+    @mock.patch('dwave.cloud.auth.flows.LeapAuthFlow.from_config_model')
+    def test_leap_project_ls(self, flow_factory, account_factory):
+        flow = flow_factory.return_value
+        account = account_factory.return_value
+
+        type(flow).token = mock.PropertyMock(return_value=dict(access_token='123'))
+        flow.token_expires_soon.return_value = False
+
+        with self.subTest('dwave leap project ls'):
+            runner = CliRunner(mix_stderr=False)
+            with runner.isolated_filesystem():
+                result = runner.invoke(cli, ['leap', 'project', 'ls'])
+
+            self.assertEqual(result.exit_code, 0)
+            account.list_projects.assert_called_once()
+
+    @mock.patch('dwave.cloud.api.resources.LeapAccount.from_config')
+    @mock.patch('dwave.cloud.auth.flows.LeapAuthFlow.from_config_model')
+    def test_leap_project_token(self, flow_factory, account_factory):
+        flow = flow_factory.return_value
+        account = account_factory.return_value
+
+        type(flow).token = mock.PropertyMock(return_value=dict(access_token='123'))
+        flow.token_expires_soon.return_value = False
+
+        projects = [
+            LeapProject(id=1, name='Project A', code='A'),
+            LeapProject(id=2, name='Project B', code='B'),
+        ]
+        project = 'b'
+
+        account.list_projects.return_value = projects
+
+        with self.subTest('dwave leap project token'):
+            runner = CliRunner(mix_stderr=False)
+            with runner.isolated_filesystem():
+                result = runner.invoke(cli, ['leap', 'project', 'token',
+                                             '--project', project])
+
+            self.assertEqual(result.exit_code, 0)
+            account.list_projects.assert_called_once()
+            account.get_project_token.assert_called_with(project=projects[1])
+
+
 @unittest.skipUnless(config, "No live server configuration available.")
 class TestCliLive(unittest.TestCase):