Skip to content

Commit

Permalink
Added features targeted for developers writing custom clients
Browse files Browse the repository at this point in the history
These changes make it easier for developers to create their own
clients by adding simple built-in token storage, and a customized
auth flow with a built-in local server. A built-in client_id was
also added to reduce the barrier of entry required for creating
a new client. Developers writing simple scripts can simply do:

    from globus_sdk import native_auth
    my_tokens = native_auth(save_tokens=True)

No functionality here is meant to replace existing SDK functionality.
Instead, the changes are intended to address commonly copied and
re-implemented code.
  • Loading branch information
NickolausDS committed Nov 2, 2018
1 parent 5277cde commit 8d7ef28
Show file tree
Hide file tree
Showing 16 changed files with 1,263 additions and 1 deletion.
8 changes: 7 additions & 1 deletion globus_sdk/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import logging

from globus_sdk.auth import AuthClient, ConfidentialAppAuthClient, NativeAppAuthClient
from globus_sdk.auth import (
AuthClient,
ConfidentialAppAuthClient,
NativeAppAuthClient,
native_auth,
)
from globus_sdk.authorizers import (
AccessTokenAuthorizer,
BasicAuthorizer,
Expand Down Expand Up @@ -48,6 +53,7 @@
"ClientCredentialsAuthorizer",
"AuthClient",
"NativeAppAuthClient",
"native_auth",
"ConfidentialAppAuthClient",
"TransferClient",
"TransferData",
Expand Down
2 changes: 2 additions & 0 deletions globus_sdk/auth/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@
)
from globus_sdk.auth.oauth2_authorization_code import GlobusAuthorizationCodeFlowManager
from globus_sdk.auth.oauth2_native_app import GlobusNativeAppFlowManager
from globus_sdk.auth.oauth2_native_app_shortcut import native_auth

__all__ = [
"AuthClient",
"NativeAppAuthClient",
"ConfidentialAppAuthClient",
"GlobusNativeAppFlowManager",
"GlobusAuthorizationCodeFlowManager",
"native_auth",
]
183 changes: 183 additions & 0 deletions globus_sdk/auth/oauth2_native_app_shortcut.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import logging
import os
import webbrowser
from socket import gethostname

from six.moves import input

from globus_sdk.auth.client_types.native_client import NativeAppAuthClient
from globus_sdk.auth.oauth2_constants import DEFAULT_REQUESTED_SCOPES
from globus_sdk.exc import ConfigError
from globus_sdk.utils.local_server import is_remote_session, start_local_server
from globus_sdk.utils.safeio import safe_print
from globus_sdk.utils.token_storage import clear_tokens, load_tokens, save_tokens

logger = logging.getLogger(__name__)

AUTH_CODE_REDIRECT = "https://auth.globus.org/v2/web/auth-code"

NATIVE_AUTH_DEFAULTS = {
"config_filename": os.path.expanduser("~/.globus-native-apps.cfg"),
"config_section": None, # Defaults to client_id if not set
"client_id": "0af96eea-fec8-4d6e-aad2-c87feed8151c",
"requested_scopes": DEFAULT_REQUESTED_SCOPES,
"refresh_tokens": False,
"prefill_named_grant": gethostname(),
"additional_auth_params": {},
"save_tokens": False,
"check_tokens_expired": True,
"force_login": False,
"no_local_server": False,
"no_browser": False,
"server_hostname": "127.0.0.1",
"server_port": 8890,
"redirect_uri": "http://localhost:8890/",
}


def native_auth(**kwargs):
"""
Provides a simple shortcut for doing a native auth flow for most use-cases
by setting common defaults for frequently used fields. Although a default
client id is provided, production apps should define their own at
https://developers.globus.org. See `NativeAppAuthClient` for constructing
a more fine-tuned native auth flow. Returns tokens organized by resource
server.
**Native App Parameters**
``client_id`` (*string*)
Client App id registered at https://developers.globus.org. Defaults
to a built-in one for testing.
``requested_scopes`` (*iterable* or *string*)
The scopes on the token(s) being requested, as a space-separated
string or iterable of strings. Defaults to ``openid profile email
urn:globus:auth:scope:transfer.api.globus.org:all``
``redirect_uri`` (*string*)
The page that users should be directed to after authenticating at
the authorize URL. Defaults to
'https://auth.globus.org/v2/web/auth-code', which displays the
resulting ``auth_code`` for users to copy-paste back into your
application (and thereby be passed back to the
``GlobusNativeAppFlowManager``)
``refresh_tokens`` (*bool*)
When True, request refresh tokens in addition to access tokens
``prefill_named_grant`` (*string*)
Optionally prefill the named grant label on the consent page
``additional_auth_params`` (*dict*)
Set ``additional_parameters`` in
NativeAppAuthClient.oauth2_get_authorize_url()
**Login Parameters**
``save_tokens`` (*bool*)
Save user tokens to disk and reload them on repeated calls.
Defaults to False.
``check_tokens_expired`` (*bool*)
Check if loaded access tokens have expired since the last login.
You should set this to False if using Refresh Tokens.
Defaults to True.
``force_login`` (*bool*)
Do not attempt to load save tokens, and complete a new auth flow
instead. Defaults to False.
``no_local_server`` (*bool*)
Do not start a local server for fetching the auth_code. Setting
this to false will require the user to copy paste a code into
the console. Defaults to False.
``no_browser`` (*bool*)
Do not automatically attempt to open a browser for the auth flow.
Defaults to False.
``server_hostname`` (*string*)
Hostname for the local server to use. No effect if
``no_local_server`` is set. MUST be specified in ``redirect_uri``.
Defaults to 127.0.0.1.
``server_port`` (*string*)
Port for the local server to use. No effect if ``no_local_server``
is set. MUST be specified in ``redirect_uri``. Defaults to 8890.
**Configfile Parameters**
``config_filename`` (*string*)
Filename to use for reading and writing values.
``config_section`` (*string*)
Section within the config file to store information (like tokens).
**Examples**
``native_auth()``
Or to save tokens: ``native_auth(save_tokens=True)``
"""
unaccepted = [k for k in kwargs.keys() if k not in NATIVE_AUTH_DEFAULTS.keys()]
if any(unaccepted):
raise ValueError("Invalid args: {}".format(unaccepted))

opts = {k: kwargs.get(k, v) for k, v in NATIVE_AUTH_DEFAULTS.items()}

# Default to the auth-code page redirect if the user is copy-pasting
if (
opts["no_local_server"] is True
and opts["redirect_uri"] == NATIVE_AUTH_DEFAULTS["redirect_uri"]
):
opts["redirect_uri"] = AUTH_CODE_REDIRECT

config_section = opts["config_section"] or opts["client_id"]

if opts["force_login"] is False:
try:
return load_tokens(
config_section, opts["requested_scopes"], opts["check_tokens_expired"]
)
except ConfigError as ce:
logger.debug(
"Loading Tokens Failed, doing auth flow instead. "
"Error: {}".format(ce)
)

# Clear previous tokens to ensure no previously saved scopes remain.
clear_tokens(config_section=config_section, client_id=opts["client_id"])

client = NativeAppAuthClient(client_id=opts["client_id"])
client.oauth2_start_flow(
requested_scopes=opts["requested_scopes"],
redirect_uri=opts["redirect_uri"],
refresh_tokens=opts["refresh_tokens"],
prefill_named_grant=opts["prefill_named_grant"],
)
url = client.oauth2_get_authorize_url(
additional_params=opts["additional_auth_params"]
)

if opts["no_local_server"] is False:
server_address = (opts["server_hostname"], opts["server_port"])
with start_local_server(listen=server_address) as server:
_prompt_login(url, opts["no_browser"])
auth_code = server.wait_for_code()
else:
_prompt_login(url, opts["no_browser"])
safe_print("Enter the resulting Authorization Code here: ", end="")
auth_code = input()

token_response = client.oauth2_exchange_code_for_tokens(auth_code)
tokens_by_resource_server = token_response.by_resource_server
if opts["save_tokens"] is True:
save_tokens(tokens_by_resource_server, config_section)
# return a set of tokens, organized by resource server name

return tokens_by_resource_server


def _prompt_login(url, no_browser):
if no_browser is False and not is_remote_session():
webbrowser.open(url, new=1)
else:
safe_print("Please paste the following URL in a browser: " "\n{}".format(url))
90 changes: 90 additions & 0 deletions globus_sdk/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,13 @@ class GlobusConfigParser(object):
"""

_GENERAL_CONF_SECTION = "general"
DEFAULT_WRITE_PATH = os.path.expanduser("~/.globus-native-apps.cfg")

def __init__(self):
logger.debug("Loading SDK Config parser")
self._parser = ConfigParser()
self._load_config()
self._write_path = self.DEFAULT_WRITE_PATH
logger.debug("Config load succeeded")

def _load_config(self):
Expand Down Expand Up @@ -134,6 +136,86 @@ def get(

return value

def get_section(self, section):
"""Attempt to lookup a section in the config file. Returns None
if no section is found."""
if self._parser.has_section(section):
return dict(self._parser.items(section))

def set_write_config_file(self, filename):
"""Set a new config file for writing to disk. Attempts to load
the new config if one exists but will not raise an error if this
fails. Future config values will be written to this location."""
logger.debug("New config file set to: {}".format(filename))
try:
self._parser.read([filename])
except Exception:
logger.debug("New config failed to load: {}".format(filename))
self._write_path = filename

def _get_write_config(self):
"""Get the config for the current configured self._write_path. If it
does not exist, an empty config is returned instead. General config
values will not be included in the returned config so they aren't
copied and written to disk.
"""
if self._write_path is None:
raise GlobusError(
"Failed to write to the config file {}, please ensure you "
"have write access.".format(self._write_path)
)

cfg = ConfigParser()
if not os.path.exists(self._write_path):
cfg[self._GENERAL_CONF_SECTION] = {}
else:
cfg.read(self._write_path)

return cfg

def _save(self, cfg):
"""Saves config options to disk at the file self._write_path. The
config file permissions are also always set to only allow User access
to the config file for a little bit of added security."""

# deny rwx to Group and World -- don't bother storing the returned
# old mask value, since we'll never restore it anyway
# do this on every call to ensure that we're always consistent about it
os.umask(0o077)
with open(self._write_path, "w") as configfile:
cfg.write(configfile)

def set(self, option, value, section):
"""
Write an option to disk using the previously configured config
at set_config_file() or .globus.cfg. Creates the section if it does
not exist.
"""
cfg = self._get_write_config()

# add the section if absent
if section not in cfg.sections():
cfg.add_section(section)

cfg.set(section, option, value)
self._save(cfg)

# Update the Global config
if section not in self._parser.sections():
self._parser.add_section(section)
self._parser.set(section, option, value)

def remove(self, option, section):
"""
Remove an option from the config. True if option previously existed,
false otherwise.
"""
cfg = self._get_write_config()
removed = cfg.remove_option(section, option)
self._save(cfg)
self._parser.remove_option(section, option)
return removed


def _get_parser():
"""
Expand All @@ -146,6 +228,14 @@ def _get_parser():
return _parser


def get_parser():
"""
Historically components only needed read-only access. Since token storage,
new components may need to lookup config values or occasionally save data
"""
return _get_parser()


# at import-time, it's None
_parser = None

Expand Down
16 changes: 16 additions & 0 deletions globus_sdk/exc.py
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,22 @@ class GlobusConnectionError(NetworkError):
"""A connection error occured while making a REST request."""


class ConfigError(GlobusError):
"""An error reading or writing from the configuration file."""


class RequestedScopesMismatch(ConfigError):
"""Requested scopes differ from scopes saved to config."""


class LoadedTokensExpired(ConfigError):
"""Tokens loaded from disk have expired since last login."""


class LocalServerError(GlobusError):
"""Error encountered with local server used for native auth."""


def convert_request_exception(exc):
"""Converts incoming requests.Exception to a Globus NetworkError"""

Expand Down
Loading

0 comments on commit 8d7ef28

Please sign in to comment.