diff --git a/docs/changelog.md b/docs/changelog.md index c8cb442..b3df47e 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,6 +1,6 @@ # Changelog -## 2.0.4.X - 2025-03-09 +## 2.0.4.X - 2025 - Initial Beta release series to shakedown public release pipelines and initial integrations. diff --git a/docs/index.md b/docs/index.md index 00eef2b..2093494 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,15 +1,15 @@ # Planet Auth Utility Library The Planet Auth Library provides generic authentication utilities for clients -and for services. For clients, it provides means to obtain access tokens that +and services. For clients, it provides the means to obtain access tokens that can be used to access network services. For services, it provides tools to validate the same access tokens. The architecture of the code was driven by OAuth2, but is intended to be easily -extensible to new authentication protocols in the future. Since both clients +extensible to new authentication protocols in the future. Since clients and resource servers are both themselves clients to authorization servers in -an OAuth2 deployment, this combining of resource client and resource server -concerns in a single library was seen as natural. +an OAuth2 deployment, this combining of client and server concerns in a single +library was seen as natural. Currently, this library supports OAuth2, Planet's legacy proprietary authentication protocols, and static API keys. diff --git a/mkdocs.yml b/mkdocs.yml index bc0e378..771e145 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -2,6 +2,7 @@ site_name: Planet Auth Library site_description: Planet Auth Library site_url: https://planet.com/ strict: true +dev_addr: 127.0.0.1:8001 #watch: # - src diff --git a/src/planet_auth/oidc/token_validator.py b/src/planet_auth/oidc/token_validator.py index ea57822..9c31a13 100644 --- a/src/planet_auth/oidc/token_validator.py +++ b/src/planet_auth/oidc/token_validator.py @@ -257,7 +257,7 @@ def validate_token( return validated_claims @staticmethod - def unverified_decode(token_str): + def hazmat_unverified_decode(token_str): # WARNING: Treat unverified token claims like toxic waste. # Nothing can be trusted until the token is verified. unverified_complete = jwt.decode_complete(token_str, options={"verify_signature": False}) # nosemgrep diff --git a/src/planet_auth_utils/__init__.py b/src/planet_auth_utils/__init__.py index 29ac18d..4957595 100644 --- a/src/planet_auth_utils/__init__.py +++ b/src/planet_auth_utils/__init__.py @@ -51,12 +51,18 @@ cmd_profile_set, cmd_profile_show, ) +from .commands.cli.jwt_cmd import ( + cmd_jwt, + cmd_jwt_decode, + cmd_jwt_validate_oauth, +) from .commands.cli.options import ( opt_api_key, opt_audience, opt_client_id, opt_client_secret, opt_human_readable, + opt_issuer, opt_loglevel, opt_long, opt_open_browser, @@ -68,8 +74,10 @@ opt_scope, opt_show_qr_code, opt_sops, + opt_token, opt_token_file, opt_username, + opt_yes_no, ) from .commands.cli.util import recast_exceptions_to_click from planet_auth_utils.constants import EnvironmentVariables @@ -81,6 +89,9 @@ __all__ = [ "cmd_plauth_embedded", "cmd_plauth_login", + "cmd_jwt", + "cmd_jwt_decode", + "cmd_jwt_validate_oauth", "cmd_oauth", "cmd_oauth_login", "cmd_oauth_refresh", @@ -110,6 +121,7 @@ "opt_client_id", "opt_client_secret", "opt_human_readable", + "opt_issuer", "opt_loglevel", "opt_long", "opt_open_browser", @@ -121,8 +133,10 @@ "opt_scope", "opt_show_qr_code", "opt_sops", + "opt_token", "opt_token_file", "opt_username", + "opt_yes_no", "recast_exceptions_to_click", # "Builtins", diff --git a/src/planet_auth_utils/commands/cli/jwt_cmd.py b/src/planet_auth_utils/commands/cli/jwt_cmd.py new file mode 100644 index 0000000..3a856bd --- /dev/null +++ b/src/planet_auth_utils/commands/cli/jwt_cmd.py @@ -0,0 +1,253 @@ +# Copyright 2025 Planet Labs PBC. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import click +import json +import pathlib +import sys +import textwrap +import time +import typing + +from planet_auth import ( + AuthException, + TokenValidator, + OidcMultiIssuerValidator, +) +from planet_auth.util import custom_json_class_dumper + +from .options import ( + opt_audience, + opt_issuer, + opt_token, + opt_token_file, + opt_human_readable, +) +from .util import recast_exceptions_to_click + + +class _jwt_human_dumps: + """ + Wrapper object for controlling the json.dumps behavior of JWTs so that + we can display a version different from what is stored in memory. + + For pretty printing JWTs, we convert timestamps into + human-readable strings. + """ + + def __init__(self, data): + self._data = data + + def __json_pretty_dumps__(self): + def _human_timestamp_iso(d): + for key, value in list(d.items()): + if key in ["iat", "exp", "nbf"] and isinstance(value, int): + fmt_time = time.strftime("%Y-%m-%dT%H:%M:%S%z", time.localtime(value)) + if (key == "exp") and (d[key] < time.time()): + fmt_time += " (Expired)" + d[key] = fmt_time + elif isinstance(value, dict): + _human_timestamp_iso(value) + return d + + json_dumps = self._data.copy() + _human_timestamp_iso(json_dumps) + return json_dumps + + +def json_dumps_for_jwt_dict(data: dict, human_readable: bool, indent: int = 2): + if human_readable: + return json.dumps(_jwt_human_dumps(data), indent=indent, sort_keys=True, default=custom_json_class_dumper) + else: + return json.dumps(data, indent=2, sort_keys=True) + + +def print_jwt_parts(raw, header, body, signature, human_readable): + if raw: + print(f"RAW:\n {raw}\n") + + if header: + print( + f'HEADER:\n{textwrap.indent(json_dumps_for_jwt_dict(data=header, human_readable=human_readable), prefix=" ")}\n' + ) + + if body: + print( + f'BODY:\n{textwrap.indent(json_dumps_for_jwt_dict(body, human_readable=human_readable), prefix=" ")}\n' + ) + + if signature: + pretty_hex_signature = "" + i = 0 + for c in signature: + if i == 0: + pass + elif (i % 16) != 0: + pretty_hex_signature += ":" + else: + pretty_hex_signature += "\n" + + pretty_hex_signature += "{:02x}".format(c) + i += 1 + + print(f'SIGNATURE:\n{textwrap.indent(pretty_hex_signature, prefix=" ")}\n') + + +def hazmat_print_jwt(token_str, human_readable): + print("UNTRUSTED JWT Decoding\n") + if token_str: + (hazmat_header, hazmat_body, hazmat_signature) = TokenValidator.hazmat_unverified_decode(token_str) + print_jwt_parts( + raw=token_str, + header=hazmat_header, + body=hazmat_body, + signature=hazmat_signature, + human_readable=human_readable, + ) + + +@click.group("jwt", invoke_without_command=True) +@click.pass_context +def cmd_jwt(ctx): + """ + JWT utility for working with tokens. These functions are primarily targeted + towards debugging usage. Many of the functions do not perform token validation. + THE CONTENTS OF UNVALIDATED TOKENS MUST BE TREATED AS UNTRUSTED AND POTENTIALLY + MALICIOUS. + """ + if ctx.invoked_subcommand is None: + click.echo(ctx.get_help()) + sys.exit(0) + + +def _get_token_or_fail(token_opt: typing.Optional[str], token_file_opt: typing.Optional[pathlib.Path]): + if token_opt: + token = token_opt + elif token_file_opt: + with open(token_file_opt, mode="r", encoding="UTF-8") as file_r: + token = file_r.read() + else: + # click.echo(ctx.get_help()) + # click.echo() + raise click.UsageError("A token must be provided.") + return token + + +@cmd_jwt.command("decode") +@click.pass_context +@opt_human_readable +@opt_token +@opt_token_file +@recast_exceptions_to_click(AuthException, FileNotFoundError) +def cmd_jwt_decode(ctx, token: str, token_file: pathlib.Path, human_readable): + """ + Decode a JWT token WITHOUT PERFORMING ANY VALIDATION. + """ + token_to_print = _get_token_or_fail(token_opt=token, token_file_opt=token_file) + hazmat_print_jwt(token_str=token_to_print, human_readable=human_readable) + + +@cmd_jwt.command("validate-oauth") +@click.pass_context +@opt_human_readable +@opt_token +@opt_token_file +@opt_audience() +@opt_issuer() +@recast_exceptions_to_click(AuthException, FileNotFoundError) +def cmd_jwt_validate_oauth(ctx, token, token_file, audience, issuer, human_readable): + """ + Perform signature validation on an RFC 9068 compliant JWT token. + The `iss` and `aud` claims will be used to look up signing keys + using OAuth2/OIDC discovery protocols and perform basic validation + checks. + + This command performs only basic signature verification and token validity + checks. For checks against auth server token revocation lists, see the `oauth` + command. For deeper checks specific to the claims and structure of + Identity or Access tokens, see the `oauth` command. + + WARNING:\n + THIS TOOL IS ABSOLUTELY INAPPROPRIATE FOR PRODUCTION TRUST USAGE. This is a + development and debugging utility. The default behavior to inspect the token + for issuer and audience information used to validate the token is wholly + incorrect for a production use case. The decision of which issuers to + trust with which audiences MUST be controlled by the service operator. + """ + token_to_validate = _get_token_or_fail(token_opt=token, token_file_opt=token_file) + (hazmat_header, hazmat_body, hazmat_signature) = TokenValidator.hazmat_unverified_decode(token_to_validate) + + if issuer: + validation_iss = issuer + else: + if not hazmat_body.get("iss"): + raise click.BadParameter( + "The provided token does not contain an `iss` claim. Is the provided JWT RFC 9068 compliant?" + ) + validation_iss = hazmat_body.get("iss") + + if audience: + validation_aud = audience + else: + if not hazmat_body.get("aud"): + raise click.BadParameter( + "The provided token does not contain an `aud` claim. Is the provided JWT RFC 9068 compliant?" + ) + hazmat_aud = hazmat_body.get("aud") + if isinstance(hazmat_aud, list): + validation_aud = hazmat_aud[0] + else: + validation_aud = hazmat_aud + + validator = OidcMultiIssuerValidator.from_auth_server_urls( + trusted_auth_server_urls=[validation_iss], audience=validation_aud, log_result=False + ) + validated_body, _ = validator.validate_access_token(token_to_validate, do_remote_revocation_check=False) + # Validation throws on error + click.echo("TOKEN OK") + print_jwt_parts( + raw=token_to_validate, + header=hazmat_header, + body=validated_body, + signature=hazmat_signature, + human_readable=human_readable, + ) + + +@cmd_jwt.command("validate-rs256") +@click.pass_context +@opt_human_readable +@opt_token +@opt_token_file +@recast_exceptions_to_click(AuthException, FileNotFoundError, NotImplementedError) +def cmd_jwt_validate_rs256(ctx, token, token_file, human_readable): + """ + Validate a JWT signed with a RS256 signature + """ + # token_to_validate = _get_token_or_fail(token_opt=token, token_file_opt=token_file) + raise NotImplementedError("Command not implemented") + + +@cmd_jwt.command("validate-hs512") +@click.pass_context +@opt_human_readable +@opt_token +@opt_token_file +@recast_exceptions_to_click(AuthException, FileNotFoundError, NotImplementedError) +def cmd_jwt_validate_hs512(ctx, token, token_file, human_readable): + """ + Validate a JWT signed with a HS512 signature + """ + # token_to_validate = _get_token_or_fail(token_opt=token, token_file_opt=token_file) + raise NotImplementedError("Command not implemented") diff --git a/src/planet_auth_utils/commands/cli/main.py b/src/planet_auth_utils/commands/cli/main.py index baf5b54..5111a6e 100644 --- a/src/planet_auth_utils/commands/cli/main.py +++ b/src/planet_auth_utils/commands/cli/main.py @@ -35,22 +35,22 @@ opt_show_qr_code, opt_sops, opt_audience, - opt_token_file, opt_scope, + opt_yes_no, ) from .oauth_cmd import cmd_oauth from .planet_legacy_auth_cmd import cmd_pllegacy from .profile_cmd import cmd_profile +from .jwt_cmd import cmd_jwt from .util import recast_exceptions_to_click, post_login_cmd_helper @click.group("plauth", invoke_without_command=True, help="Planet authentication utility") @opt_loglevel @opt_profile -@opt_token_file # Remove? The interactions with changing the profile in login are not great. @click.pass_context @recast_exceptions_to_click(AuthException, FileNotFoundError, PermissionError) -def cmd_plauth(ctx, loglevel, auth_profile, token_file): +def cmd_plauth(ctx, loglevel, auth_profile): """ Planet Auth Utility commands """ @@ -67,7 +67,7 @@ def cmd_plauth(ctx, loglevel, auth_profile, token_file): ctx.obj["AUTH"] = PlanetAuthFactory.initialize_auth_client_context( auth_profile_opt=auth_profile, - token_file_opt=token_file, + # token_file_opt=token_file, ) @@ -106,7 +106,17 @@ def cmd_plauth_version(): """ Show the version of planet auth components. """ - print(f"planet-auth : {importlib.metadata.version('planet-auth')}") + + def _pkg_display_version(pkg_name): + try: + return importlib.metadata.version(pkg_name) + except importlib.metadata.PackageNotFoundError: + return "N/A" + + # Well known packages with built-in profile configs we commonly use. + print(f"planet-auth : {_pkg_display_version('planet-auth')}") + print(f"planet-auth-config : {_pkg_display_version('planet-auth-config')}") + print(f"planet : {_pkg_display_version('planet')}") @cmd_plauth.command("login") @@ -123,6 +133,7 @@ def cmd_plauth_version(): @opt_username() @opt_password() @opt_sops +@opt_yes_no @click.pass_context @recast_exceptions_to_click(AuthException, FileNotFoundError, PermissionError) def cmd_plauth_login( @@ -140,6 +151,7 @@ def cmd_plauth_login( username, password, sops, + yes, ): """ Perform an initial login, obtain user authorization, and save access @@ -182,19 +194,18 @@ def cmd_plauth_login( ) print("Login succeeded.") # Errors should throw. - post_login_cmd_helper( - override_auth_context=override_auth_context, - use_sops=sops, - ) + post_login_cmd_helper(override_auth_context=override_auth_context, use_sops=sops, prompt_pre_selection=yes) cmd_plauth.add_command(cmd_oauth) cmd_plauth.add_command(cmd_pllegacy) cmd_plauth.add_command(cmd_profile) +cmd_plauth.add_command(cmd_jwt) cmd_plauth_embedded.add_command(cmd_oauth) cmd_plauth_embedded.add_command(cmd_pllegacy) cmd_plauth_embedded.add_command(cmd_profile) +cmd_plauth_embedded.add_command(cmd_jwt) cmd_plauth_embedded.add_command(cmd_plauth_login) cmd_plauth_embedded.add_command(cmd_plauth_version) diff --git a/src/planet_auth_utils/commands/cli/oauth_cmd.py b/src/planet_auth_utils/commands/cli/oauth_cmd.py index cdcadf9..90a57bc 100644 --- a/src/planet_auth_utils/commands/cli/oauth_cmd.py +++ b/src/planet_auth_utils/commands/cli/oauth_cmd.py @@ -13,10 +13,7 @@ # limitations under the License. import click -import json import sys -import textwrap -import time from planet_auth import ( AuthException, @@ -24,9 +21,7 @@ OidcAuthClient, ExpiredTokenException, ClientCredentialsAuthClientBase, - TokenValidator, ) -from planet_auth.util import custom_json_class_dumper from .options import ( opt_audience, @@ -42,71 +37,10 @@ opt_show_qr_code, opt_sops, opt_username, + opt_yes_no, ) from .util import recast_exceptions_to_click, post_login_cmd_helper, print_obj - - -class _jwt_human_dumps: - """ - Wrapper object for controlling the json.dumps behavior of JWTs so that - we can display a version different from what is stored in memory. - - For pretty printing JWTs, we convert timestamps into - human-readable strings. - """ - - def __init__(self, data): - self._data = data - - def __json_pretty_dumps__(self): - def _human_timestamp_iso(d): - for key, value in list(d.items()): - if key in ["iat", "exp", "nbf"] and isinstance(value, int): - fmt_time = time.strftime("%Y-%m-%dT%H:%M:%S%z", time.localtime(value)) - if (key == "exp") and (d[key] < time.time()): - fmt_time += " (Expired)" - d[key] = fmt_time - elif isinstance(value, dict): - _human_timestamp_iso(value) - return d - - json_dumps = self._data.copy() - _human_timestamp_iso(json_dumps) - return json_dumps - - -def _json_dumps_for_jwt_dict(data: dict, human_readable: bool): - if human_readable: - return json.dumps(_jwt_human_dumps(data), indent=2, sort_keys=True, default=custom_json_class_dumper) - else: - return json.dumps(data, indent=2, sort_keys=True) - - -def _print_jwt(token_str, human_readable): - print("Untrusted JWT Decoding\n") - print(f"RAW:\n {token_str}\n") - if token_str: - (header, body, signature) = TokenValidator.unverified_decode(token_str) - pretty_hex_signature = "" - i = 0 - for c in signature: - if i == 0: - pass - elif (i % 16) != 0: - pretty_hex_signature += ":" - else: - pretty_hex_signature += "\n" - - pretty_hex_signature += "{:02x}".format(c) - i += 1 - - print( - f'HEADER:\n{textwrap.indent(_json_dumps_for_jwt_dict(data=header, human_readable=human_readable), prefix=" ")}\n' - ) - print( - f'BODY:\n{textwrap.indent(_json_dumps_for_jwt_dict(body, human_readable=human_readable), prefix=" ")}\n' - ) - print(f'SIGNATURE:\n{textwrap.indent(pretty_hex_signature, prefix=" ")}\n') +from .jwt_cmd import json_dumps_for_jwt_dict, hazmat_print_jwt def _check_client_type(ctx): @@ -142,6 +76,7 @@ def cmd_oauth(ctx): @opt_client_id @opt_client_secret @opt_sops +@opt_yes_no @click.pass_context @recast_exceptions_to_click(AuthException) def cmd_oauth_login( @@ -156,6 +91,7 @@ def cmd_oauth_login( auth_client_id, auth_client_secret, sops, + yes, project, ): """ @@ -184,10 +120,7 @@ def cmd_oauth_login( extra=extra, ) print("Login succeeded.") # Errors should throw. - post_login_cmd_helper( - override_auth_context=current_auth_context, - use_sops=sops, - ) + post_login_cmd_helper(override_auth_context=current_auth_context, use_sops=sops, prompt_pre_selection=yes) @cmd_oauth.command("refresh") @@ -248,7 +181,7 @@ def cmd_oauth_validate_access_token_remote(ctx, human_readable): print_obj("INVALID") sys.exit(1) # print_obj(validation_json) - print(_json_dumps_for_jwt_dict(data=validation_json, human_readable=human_readable)) + print(json_dumps_for_jwt_dict(data=validation_json, human_readable=human_readable)) @cmd_oauth.command("validate-access-token-local") @@ -281,7 +214,7 @@ def cmd_oauth_validate_access_token_local(ctx, audience, scope, human_readable): access_token=saved_token.access_token(), required_audience=audience, scopes_anyof=scope ) # print_obj(validation_json) - print(_json_dumps_for_jwt_dict(data=validation_json, human_readable=human_readable)) + print(json_dumps_for_jwt_dict(data=validation_json, human_readable=human_readable)) @cmd_oauth.command("validate-id-token") @@ -302,7 +235,7 @@ def cmd_oauth_validate_id_token_remote(ctx, human_readable): print_obj("INVALID") sys.exit(1) # print_obj(validation_json) - print(_json_dumps_for_jwt_dict(data=validation_json, human_readable=human_readable)) + print(json_dumps_for_jwt_dict(data=validation_json, human_readable=human_readable)) @cmd_oauth.command("validate-id-token-local") @@ -322,7 +255,7 @@ def cmd_oauth_validate_id_token_local(ctx, human_readable): # Throws on error. validation_json = auth_client.validate_id_token_local(saved_token.id_token()) # print_obj(validation_json) - print(_json_dumps_for_jwt_dict(data=validation_json, human_readable=human_readable)) + print(json_dumps_for_jwt_dict(data=validation_json, human_readable=human_readable)) @cmd_oauth.command("validate-refresh-token") @@ -343,7 +276,7 @@ def cmd_oauth_validate_refresh_token_remote(ctx, human_readable): print_obj("INVALID") sys.exit(1) # print_obj(validation_json) - print(_json_dumps_for_jwt_dict(data=validation_json, human_readable=human_readable)) + print(json_dumps_for_jwt_dict(data=validation_json, human_readable=human_readable)) @cmd_oauth.command("revoke-access-token") @@ -448,7 +381,7 @@ def cmd_oauth_decode_jwt_access_token(ctx, human_readable): access tokens in other formats. """ saved_token = FileBackedOidcCredential(None, ctx.obj["AUTH"].token_file_path()) - _print_jwt(saved_token.access_token(), human_readable=human_readable) + hazmat_print_jwt(saved_token.access_token(), human_readable=human_readable) @cmd_oauth.command("decode-id-token") @@ -462,7 +395,7 @@ def cmd_oauth_decode_jwt_id_token(ctx, human_readable): debugging purposes. """ saved_token = FileBackedOidcCredential(None, ctx.obj["AUTH"].token_file_path()) - _print_jwt(saved_token.id_token(), human_readable=human_readable) + hazmat_print_jwt(saved_token.id_token(), human_readable=human_readable) @cmd_oauth.command("decode-refresh-token") @@ -478,4 +411,4 @@ def cmd_oauth_decode_jwt_refresh_token(ctx, human_readable): refresh tokens in other formats. """ saved_token = FileBackedOidcCredential(None, ctx.obj["AUTH"].token_file_path()) - _print_jwt(saved_token.refresh_token(), human_readable=human_readable) + hazmat_print_jwt(saved_token.refresh_token(), human_readable=human_readable) diff --git a/src/planet_auth_utils/commands/cli/options.py b/src/planet_auth_utils/commands/cli/options.py index 56b1af3..f90c28e 100644 --- a/src/planet_auth_utils/commands/cli/options.py +++ b/src/planet_auth_utils/commands/cli/options.py @@ -13,6 +13,7 @@ # limitations under the License. import click +import pathlib from planet_auth_utils.constants import EnvironmentVariables @@ -191,6 +192,20 @@ def opt_loglevel(function): return function +def opt_yes_no(function): + """ + Click option to bypass prompts with a yes or no selection. + """ + function = click.option( + "--yes/--no", + "-y/-n", + help='Skip user prompts with a "yes" or "no" selection', + default=None, + show_default=True, + )(function) + return function + + def opt_human_readable(function): """ Click option to toggle raw / human-readable formatting. @@ -247,6 +262,21 @@ def opt_show_qr_code(function): return function +def opt_token(function): + """ + Click option for specifying a token literal. + """ + function = click.option( + "--token", + help="Token string.", + type=str, + # envvar=EnvironmentVariables.AUTH_TOKEN, + show_envvar=False, + show_default=False, + )(function) + return function + + def opt_token_file(function): """ Click option for specifying a token file location for the @@ -254,16 +284,37 @@ def opt_token_file(function): """ function = click.option( "--token-file", - type=click.Path(), + type=click.Path(exists=True, file_okay=True, readable=True, path_type=pathlib.Path), envvar=EnvironmentVariables.AUTH_TOKEN_FILE, - help="Auth token file. The default will be to use a location in the profile directory ~/.planet/", + help="File containing a token.", default=None, - show_envvar=True, + show_envvar=False, show_default=True, )(function) return function +def opt_issuer(required=False): + def decorator(function): + """ + Click option for specifying an OAuth token issuer for the + planet_auth package's click commands. + """ + function = click.option( + "--issuer", + type=str, + envvar=EnvironmentVariables.AUTH_ISSUER, + help="Token issuer.", + default=None, + show_envvar=False, + show_default=False, + required=required, + )(function) + return function + + return decorator + + def opt_audience(required=False): def decorator(function): """ @@ -275,10 +326,9 @@ def decorator(function): multiple=True, type=str, envvar=EnvironmentVariables.AUTH_AUDIENCE, - help="Token audiences to request. Specify multiple options to request" + help="Token audiences. Specify multiple options to set" " multiple audiences. When set via environment variable, audiences" - " should be white space delimited. Default value is determined" - " by the selected auth profile.", + " should be white space delimited.", default=None, show_envvar=True, show_default=True, diff --git a/src/planet_auth_utils/commands/cli/planet_legacy_auth_cmd.py b/src/planet_auth_utils/commands/cli/planet_legacy_auth_cmd.py index 59b265f..acb4a0e 100644 --- a/src/planet_auth_utils/commands/cli/planet_legacy_auth_cmd.py +++ b/src/planet_auth_utils/commands/cli/planet_legacy_auth_cmd.py @@ -22,7 +22,7 @@ PlanetLegacyAuthClientConfig, ) -from .options import opt_password, opt_sops, opt_username +from .options import opt_password, opt_sops, opt_username, opt_yes_no from .util import recast_exceptions_to_click, post_login_cmd_helper @@ -51,8 +51,9 @@ def cmd_pllegacy(ctx): @opt_password(hidden=False) @opt_username(hidden=False) @opt_sops +@opt_yes_no @click.pass_context -def cmd_pllegacy_login(ctx, username, password, sops): +def cmd_pllegacy_login(ctx, username, password, sops, yes): """ Perform an initial login using Planet's legacy authentication interfaces. """ @@ -64,10 +65,7 @@ def cmd_pllegacy_login(ctx, username, password, sops): password=password, ) print("Login succeeded.") # Errors should throw. - post_login_cmd_helper( - override_auth_context=current_auth_context, - use_sops=sops, - ) + post_login_cmd_helper(override_auth_context=current_auth_context, use_sops=sops, prompt_pre_selection=yes) @cmd_pllegacy.command("print-api-key") diff --git a/src/planet_auth_utils/commands/cli/prompts.py b/src/planet_auth_utils/commands/cli/prompts.py index 68db18a..7deba9d 100644 --- a/src/planet_auth_utils/commands/cli/prompts.py +++ b/src/planet_auth_utils/commands/cli/prompts.py @@ -17,6 +17,7 @@ # and click or simple code for others. import click +from typing import Optional # from prompt_toolkit.shortcuts import input_dialog, radiolist_dialog, yes_no_dialog @@ -25,8 +26,8 @@ from planet_auth_utils.constants import EnvironmentVariables -def prompt_change_user_default_profile_if_different( - candidate_profile_name: str, +def prompt_and_change_user_default_profile_if_different( + candidate_profile_name: str, change_default_selection: Optional[bool] = None ): config_file = PlanetAuthUserConfigEnhanced() try: @@ -39,6 +40,8 @@ def prompt_change_user_default_profile_if_different( # Since CLI options and env vars are higher priority than this file, # it should not cause surprises. do_change_default = True + elif change_default_selection is not None: + do_change_default = change_default_selection else: do_change_default = False if saved_profile_name != candidate_profile_name: diff --git a/src/planet_auth_utils/commands/cli/util.py b/src/planet_auth_utils/commands/cli/util.py index 083349b..929714d 100644 --- a/src/planet_auth_utils/commands/cli/util.py +++ b/src/planet_auth_utils/commands/cli/util.py @@ -15,6 +15,7 @@ import click import functools import json +from typing import Optional import planet_auth from planet_auth.constants import AUTH_CONFIG_FILE_SOPS, AUTH_CONFIG_FILE_PLAIN @@ -22,7 +23,7 @@ from planet_auth_utils.builtins import Builtins from planet_auth_utils.profile import Profile -from .prompts import prompt_change_user_default_profile_if_different +from .prompts import prompt_and_change_user_default_profile_if_different def recast_exceptions_to_click(*exceptions, **params): # pylint: disable=W0613 @@ -48,7 +49,9 @@ def print_obj(obj): print(json_str) -def post_login_cmd_helper(override_auth_context: planet_auth.Auth, use_sops): +def post_login_cmd_helper( + override_auth_context: planet_auth.Auth, use_sops, prompt_pre_selection: Optional[bool] = None +): override_profile_name = override_auth_context.profile_name() if not override_profile_name: # Can't save to a profile if there is none. We don't really expect this in the cases @@ -57,7 +60,9 @@ def post_login_cmd_helper(override_auth_context: planet_auth.Auth, use_sops): # If someone performed a login with a non-default profile, it's # reasonable to ask if they intend to change their defaults. - prompt_change_user_default_profile_if_different(candidate_profile_name=override_profile_name) + prompt_and_change_user_default_profile_if_different( + candidate_profile_name=override_profile_name, change_default_selection=prompt_pre_selection + ) # If the config was created ad-hoc by the factory, the factory does # not associate it with a file to support factory use in a context diff --git a/src/planet_auth_utils/constants.py b/src/planet_auth_utils/constants.py index 53bc571..a2a6648 100644 --- a/src/planet_auth_utils/constants.py +++ b/src/planet_auth_utils/constants.py @@ -38,14 +38,24 @@ class EnvironmentVariables: Name of a profile to use for auth client configuration. """ + AUTH_TOKEN = "PL_AUTH_TOKEN" + """ + Literal token string. + """ + AUTH_TOKEN_FILE = "PL_AUTH_TOKEN_FILE" """ - File path to use for storing OAuth tokens. + File path to use for storing tokens. + """ + + AUTH_ISSUER = "PL_AUTH_ISSUER" + """ + Issuer to use when requesting or validating OAuth tokens. """ AUTH_AUDIENCE = "PL_AUTH_AUDIENCE" """ - Audience to use when requesting OAuth tokens. + Audience to use when requesting or validating OAuth tokens. """ AUTH_ORGANIZATION = "PL_AUTH_ORGANIZATION" diff --git a/src/planet_auth_utils/plauth_factory.py b/src/planet_auth_utils/plauth_factory.py index 29a3cf1..9085c51 100644 --- a/src/planet_auth_utils/plauth_factory.py +++ b/src/planet_auth_utils/plauth_factory.py @@ -235,7 +235,7 @@ def initialize_auth_client_context( auth_client_id_opt: Optional[str] = None, auth_client_secret_opt: Optional[str] = None, auth_api_key_opt: Optional[str] = None, # Deprecated - token_file_opt: Optional[str] = None, # TODO: Remove, but we still depend on it for Planet Legacy use cases. + token_file_opt: Optional[str] = None, # TODO: Remove? but we still depend on it for Planet Legacy use cases. # TODO?: initial_token_data: dict = None, save_token_file: bool = True, save_profile_config: bool = False,