Skip to content

Commit c13c6af

Browse files
feat: phase auth aws iam (#262)
* refactor: rename get_default_user_id to get_default_account_id and add deprecation notice - Renamed the function `get_default_user_id` to `get_default_account_id` for clarity and to better reflect its purpose. - Updated the function's docstring to indicate it now handles both user accounts and service accounts. - Added a deprecated version of `get_default_user_id` that calls the new function for backward compatibility. * refactor: update keyring service name to use account ID - Replaced the usage of `get_default_user_id` with `get_default_account_id` in the keyring service name generation for improved clarity and consistency with recent changes. * fix: handle missing email for default user in whoami command - Updated the whoami command to display 'N/A (Service Account)' if the default user's email is not available, improving clarity for service accounts. - Renamed 'User ID' to 'Account ID' for consistency with recent changes. * refactor: update user switch functionality for consistency - Changed terminology from 'User ID' to 'Account ID' for clarity. - Updated email handling to display 'Service Account' when applicable. - Adjusted prompts and error messages to reflect the new account terminology. * refactor: replace user ID references with account ID in logout functionality - Updated the logout functionality to use `get_default_account_id` instead of `get_default_user_id` for consistency with recent changes. - Adjusted keyring password deletion and configuration updates to reflect the new account terminology. * refactor: remove unused user ID reference in import_env.py - Eliminated the import of `get_default_user_id` from `phase_cli.utils.misc` as it is no longer needed, streamlining the code for better clarity and consistency. * refactor: enhance token-based authentication flow - Updated the authentication process to support both Personal Access Tokens (PATs) and Service Account Tokens, improving flexibility. - Introduced checks for the PHASE_HOST environment variable to allow headless operation. - Replaced user ID references with account ID for consistency across the authentication flow. - Enhanced error handling and user prompts to accommodate service accounts and ensure clarity in user interactions. * refactor: enhance authentication flow for Personal Access Tokens - Added support for Personal Access Tokens (PATs) by prompting for user email when a PAT is detected. - Improved handling of unknown token formats to ensure user email is requested for clarity and safety. - Streamlined the authentication process to accommodate both PATs and Service Account Tokens. * chore(deps): add boto3 and botocore dependencies - Added boto3 and botocore to requirements.txt to support AWS service integration. - Specified minimum versions for both libraries to ensure compatibility. * feat(auth): implement web-based and token-based authentication - Introduced a new authentication module with support for web-based and token-based authentication methods. - Added an HTTP server to handle authentication requests and process user credentials securely. - Enhanced user experience by providing clear prompts for both Personal Access Tokens and AWS IAM credentials. - Integrated error handling and logging for improved feedback during the authentication process. * refactor: remove print_phase_links function from misc.py - Eliminated the print_phase_links function to streamline the codebase and improve clarity. - This function was previously responsible for displaying a welcome message and links to community resources. * feat(auth): add AWS IAM authentication module - Introduced a new module for AWS IAM authentication, enabling integration with Phase API. - Implemented functions to sign requests and authenticate using AWS credentials. - Added support for custom STS endpoints and region resolution. - Enhanced error handling for missing AWS credentials and authentication failures. * feat(auth): enhance authentication options with AWS IAM support - Updated the authentication command to include AWS IAM as a mode of authentication. - Added a new argument for Service Account ID, required when using AWS IAM mode. - Adjusted the phase_auth function call to accommodate the new service_account_id parameter. * chore(deps): update botocore version in requirements.txt - Changed the minimum version of botocore to 1.40.17 for improved compatibility with AWS services. - Removed the specific version constraint for boto3 to allow for more flexibility in dependency resolution. * refactor(auth): streamline AWS session handling in authentication module - Replaced boto3 session initialization with botocore's get_session for improved compatibility and flexibility. - Enhanced region resolution by incorporating environment variable support for AWS_DEFAULT_REGION. - Updated credential retrieval to ensure consistent handling of AWS credentials across the authentication process. * refactor(auth): simplify region and endpoint resolution in AWS authentication - Refactored the `resolve_region_and_endpoint` function to eliminate unnecessary parameters and improve clarity. - Integrated botocore's `Config` for better handling of AWS region detection. - Removed the custom STS endpoint parameter from the `perform_aws_iam_auth` function to streamline the authentication process. * feat(auth): add optional TTL parameter for AWS IAM authentication - Updated the `phase_auth` function to include an optional `ttl` parameter for specifying token time-to-live in seconds when using AWS IAM mode. - Adjusted the call to `perform_aws_iam_auth` to pass the new `ttl` argument, enhancing flexibility in token management. * refactor(logout): replace print statements with rich console output - Updated the logout functionality to use the rich console for better error handling and user feedback. - Enhanced messages for logging out, purging data, and configuration errors to improve clarity and user experience. * feat(auth): update authentication command to support external identities and TTL - Modified the `auth` command to include a new argument for Service Account ID, clarifying its use for external identities. - Added an optional `ttl` parameter for specifying token time-to-live, enhancing flexibility in token management during authentication. - Updated the `phase_auth` function call to accommodate the new `ttl` argument. * feat(auth): add no-login option to phase_auth function - Introduced a new `no_login` parameter to the `phase_auth` function, allowing users to bypass the login process and print raw AWS IAM authentication results directly. - Updated the function's logic to handle the new parameter, enhancing flexibility for users who may want to view authentication results without logging in. * feat(auth): enhance authentication command with no-login option - Added a `--no-login` argument to the authentication command, allowing users to print authentication tokens directly to stdout without logging in, specifically for external identity modes like aws-iam. - Updated the `phase_auth` function call to incorporate the new `no_login` parameter, improving user experience and flexibility in authentication processes. * chore: bump version to 1.20.0 in APKBUILD and const.py * feat(auth): rename no-login option to no-store for clarity - Updated the `--no-login` argument to `--no-store` in the authentication command, clarifying its purpose to print authentication token responses without storing credentials. - Adjusted the `phase_auth` function to reflect this change, enhancing the user experience and understanding of the authentication process. * chore: reset changes from bad rebase * feat: use consistent routing pattern * chore: remove phase cloud / self-hosted host switcher * feat: add AWS IAM authentication support to Phase API - Introduced a new function `external_identity_auth_aws` for authenticating with Phase using AWS IAM credentials. - Added a utility function `b64_str` for Base64 encoding strings, used in the authentication payload. - Enhanced error handling for SSL and connection errors during the authentication process. * refactor: streamline AWS IAM authentication flow - Removed the `authenticate_with_phase` function and replaced it with `external_identity_auth_aws` for improved clarity and modularity. - Updated parameter names in `perform_aws_iam_auth` for consistency. - Simplified the authentication process by leveraging the new utility function for AWS IAM credentials. * fix: update parameter naming in AWS IAM authentication call - Changed the parameter name in the `perform_aws_iam_auth` function call for clarity and consistency, aligning with recent refactoring efforts. * chore: bump version to 1.21.0 in APKBUILD and const.py * fix: remove unused import of PHASE_CLOUD_PUBLIC_API_HOST from misc.py * feat: add AWS configuration constants for STS endpoint and region - Introduced AWS_DEFAULT_GLOBAL_STS_ENDPOINT and AWS_DEFAULT_GLOBAL_STS_REGION constants to facilitate AWS service integration. - Updated PHASE_CLOUD_API_HOST for clarity in configuration management. * refactor: replace hardcoded STS endpoint and region with constants - Updated the `resolve_region_and_endpoint` function to utilize the newly introduced `AWS_DEFAULT_GLOBAL_STS_ENDPOINT` and `AWS_DEFAULT_GLOBAL_STS_REGION` constants for improved maintainability and clarity. * fix: update AWS IAM authentication URL for external identities - Changed the endpoint in the `external_identity_auth_aws` function to reflect the correct routing for external identity authentication with AWS IAM. * fix: add trailing slash Signed-off-by: rohan <[email protected]> * fix: ensure CLI exits successfully with no arguments - Added a check to display top-level help and exit with code 0 when no arguments are provided to the CLI. - This serves as a temporary fix to improve user experience. * fix: improve error handling in phase_auth function - Updated the phase_auth function to exit with code 2 when required parameters are missing or invalid, enhancing user experience and preventing further execution in error scenarios. --------- Signed-off-by: rohan <[email protected]> Co-authored-by: rohan <[email protected]>
1 parent 8600541 commit c13c6af

File tree

10 files changed

+376
-126
lines changed

10 files changed

+376
-126
lines changed

APKBUILD

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Maintainer: Phase <[email protected]>
22
pkgname=phase
3-
pkgver=1.20.0
3+
pkgver=1.21.0
44
pkgrel=0
55
pkgdesc="Phase CLI"
66
url="https://phase.dev"

phase_cli/cmd/auth/__init__.py

Whitespace-only changes.

phase_cli/cmd/auth.py renamed to phase_cli/cmd/auth/auth.py

Lines changed: 78 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@
1010
import base64
1111
import time, random
1212
import questionary
13-
from phase_cli.utils.misc import open_browser, validate_url, print_phase_links
13+
from phase_cli.utils.misc import open_browser, validate_url
1414
from phase_cli.utils.crypto import CryptoUtils
1515
from phase_cli.utils.phase_io import Phase
1616
from phase_cli.utils.const import PHASE_SECRETS_DIR, PHASE_CLOUD_API_HOST
17+
from phase_cli.cmd.auth.aws import perform_aws_iam_auth
1718
from rich.console import Console
1819

1920
class SimpleHTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
@@ -93,9 +94,9 @@ def do_POST(self):
9394
return httpd
9495

9596

96-
def phase_auth(mode="webauth"):
97+
def phase_auth(mode="webauth", service_account_id=None, ttl=None, no_store=False):
9798
"""
98-
Handles authentication for the Phase CLI using either web-based or token-based authentication.
99+
Handles authentication for the Phase CLI using web-based, token-based, or AWS IAM authentication.
99100
100101
If a user is already authenticated, the function will notify the user of their logged-in status and provide instructions for logging out and logging back in.
101102
@@ -114,8 +115,16 @@ def phase_auth(mode="webauth"):
114115
- Asks the user for their email and personal access token.
115116
- Validates the credentials and writes them to the keyring.
116117
118+
For aws-iam:
119+
- Uses AWS IAM credentials to authenticate with Phase.
120+
- Requires a service account ID to be provided.
121+
- Signs an AWS STS GetCallerIdentity request and sends it to Phase for verification.
122+
- Receives a Phase token in response and stores it in the keyring.
123+
117124
Args:
118-
- mode (str): The mode of authentication to use. Default is "webauth". Can be either "webauth" or "token".
125+
- mode (str): The mode of authentication to use. Default is "webauth". Can be either "webauth", "token", or "aws-iam".
126+
- service_account_id (str): Required for aws-iam mode. The service account ID to authenticate with.
127+
- ttl (int): Optional for aws-iam mode. Token TTL in seconds.
119128
120129
Returns:
121130
None
@@ -125,8 +134,66 @@ def phase_auth(mode="webauth"):
125134

126135
server = None
127136
try:
128-
# Choose the authentication mode: webauth (default) or token-based.
129-
if mode == 'token':
137+
# Choose the authentication mode: webauth (default), token-based, or aws-iam.
138+
if mode == 'aws-iam':
139+
# AWS IAM authentication
140+
if not service_account_id:
141+
console.log("Error: --service-account-id is required when using --mode aws-iam")
142+
sys.exit(2)
143+
144+
# Check if PHASE_HOST environment variable is set for headless operation
145+
PHASE_API_HOST = os.getenv("PHASE_HOST")
146+
147+
if PHASE_API_HOST:
148+
console.log(f"Using PHASE_HOST environment variable: {PHASE_API_HOST}")
149+
else:
150+
# Interactive mode: ask user to choose instance type
151+
phase_instance_type = questionary.select(
152+
'Choose your Phase instance type:',
153+
choices=['☁️ Phase Cloud', '🛠️ Self Hosted']
154+
).ask()
155+
156+
if not phase_instance_type:
157+
console.log("\nExiting phase...")
158+
return
159+
160+
if phase_instance_type == '🛠️ Self Hosted':
161+
PHASE_API_HOST = questionary.text("Please enter your host (URL eg. https://example.com/path):").ask()
162+
if not PHASE_API_HOST:
163+
console.log("\nExiting phase...")
164+
return
165+
else:
166+
PHASE_API_HOST = PHASE_CLOUD_API_HOST
167+
168+
# Perform AWS IAM authentication
169+
try:
170+
console.log("Authenticating with AWS IAM credentials...")
171+
aws_result = perform_aws_iam_auth(host=PHASE_API_HOST, service_account_id=service_account_id, ttl=ttl)
172+
173+
# Extract the token from the AWS auth response
174+
auth_data = aws_result.get("authentication", {})
175+
auth_token = auth_data.get("token")
176+
177+
if not auth_token:
178+
raise ValueError("No token received from AWS IAM authentication")
179+
180+
console.log("AWS IAM authentication successful")
181+
182+
# If user requested no-store, print raw result and exit early
183+
if no_store:
184+
print(json.dumps(aws_result, indent=4))
185+
return
186+
187+
# Validate the token with Phase API by initializing Phase client
188+
phase = Phase(init=False, pss=auth_token, host=PHASE_API_HOST)
189+
result = phase.auth()
190+
user_email = None # Service accounts don't have emails
191+
192+
except Exception as e:
193+
console.log(f"AWS IAM authentication failed: {e}")
194+
return
195+
196+
elif mode == 'token':
130197
# Manual token-based authentication
131198
# Check if PHASE_HOST environment variable is set for headless operation
132199
PHASE_API_HOST = os.getenv("PHASE_HOST")
@@ -148,6 +215,7 @@ def phase_auth(mode="webauth"):
148215
PHASE_API_HOST = questionary.text("Please enter your host (URL eg. https://example.com/path):").ask()
149216
if not PHASE_API_HOST:
150217
console.log("\nExiting phase...")
218+
sys.exit(2)
151219
return
152220
else:
153221
PHASE_API_HOST = PHASE_CLOUD_API_HOST
@@ -206,6 +274,7 @@ def phase_auth(mode="webauth"):
206274

207275
if not validate_url(PHASE_API_HOST):
208276
console.log("Invalid URL. Please ensure you include the scheme (e.g., https) and domain. Keep in mind, path and port are optional.")
277+
sys.exit(2)
209278
return
210279

211280
# Start an HTTP web server at a random port and spin up the keys.
@@ -306,11 +375,9 @@ def phase_auth(mode="webauth"):
306375
json.dump(config_data, f, indent=4)
307376

308377
if token_saved_in_keyring:
309-
print("\033[1;32m✅ Authentication successful.\033[0m")
378+
console.print("[bold green]✅ Authentication successful.[/bold green]")
310379
else:
311-
print("\033[1;32m✅ Authentication successful.\033[0m")
312-
print("\033[1;36m🎉 Welcome to Phase CLI!\033[0m\n")
313-
print_phase_links()
380+
console.print("[bold green]✅ Authentication successful.[/bold green]")
314381

315382
else:
316383
console.log("Failed to authenticate with the provided credentials.")
@@ -321,4 +388,4 @@ def phase_auth(mode="webauth"):
321388
sys.exit(1)
322389
finally:
323390
if server:
324-
server.shutdown()
391+
server.shutdown()

phase_cli/cmd/auth/aws.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
from botocore.awsrequest import AWSRequest
2+
from botocore.auth import SigV4Auth
3+
from botocore.credentials import get_credentials
4+
from botocore.session import get_session
5+
from botocore.config import Config
6+
from phase_cli.utils.network import external_identity_auth_aws
7+
from phase_cli.utils.const import AWS_DEFAULT_GLOBAL_STS_REGION, AWS_DEFAULT_GLOBAL_STS_ENDPOINT
8+
9+
10+
def resolve_region_and_endpoint() -> tuple[str, str]:
11+
session = get_session()
12+
aws_region = session.get_config_variable('region')
13+
if not aws_region:
14+
try:
15+
client_config = Config(region_name=None)
16+
session.create_client('sts', config=client_config)
17+
aws_region = session.get_config_variable('region')
18+
except Exception:
19+
pass
20+
21+
if aws_region:
22+
return aws_region, f"https://sts.{aws_region}.amazonaws.com"
23+
24+
# Fallback to legacy global endpoint, sign with us-east-1
25+
return AWS_DEFAULT_GLOBAL_STS_REGION, AWS_DEFAULT_GLOBAL_STS_ENDPOINT
26+
27+
28+
def sign_get_caller_identity(region: str, endpoint: str, method: str = "POST") -> tuple[str, dict, str]:
29+
"""
30+
Returns (signed_url, signed_headers, body) for GetCallerIdentity.
31+
Uses header-based SigV4 (includes X-Amz-Date header).
32+
"""
33+
# STS Query API (Action=GetCallerIdentity&Version=2011-06-15)
34+
body = "Action=GetCallerIdentity&Version=2011-06-15"
35+
headers = {"Content-Type": "application/x-www-form-urlencoded; charset=utf-8"}
36+
37+
session = get_session()
38+
creds = session.get_credentials()
39+
if creds is None:
40+
raise SystemExit("No AWS credentials found. On EC2, attach an instance profile or set AWS_* env vars.")
41+
42+
frozen = creds.get_frozen_credentials()
43+
req = AWSRequest(method=method, url=endpoint, data=body, headers=headers)
44+
SigV4Auth(frozen, "sts", region).add_auth(req)
45+
prepared = req.prepare()
46+
47+
signed_url = prepared.url
48+
signed_headers = dict(prepared.headers.items())
49+
return signed_url, signed_headers, body
50+
51+
52+
def perform_aws_iam_auth(host: str, service_account_id: str, ttl: int | None = None, method: str = "POST"):
53+
"""
54+
Perform complete AWS IAM authentication flow with Phase.
55+
56+
Args:
57+
host: Phase API base URL
58+
service_account_id: Service Account ID to authenticate (UUID)
59+
ttl: Requested token TTL in seconds (optional)
60+
method: HTTP method to sign (default: POST)
61+
62+
Returns:
63+
dict: Authentication response from Phase API containing token
64+
"""
65+
region, endpoint = resolve_region_and_endpoint()
66+
signed = sign_get_caller_identity(region=region, endpoint=endpoint, method=method)
67+
result = external_identity_auth_aws(host, service_account_id, ttl, signed, method=method)
68+
return result

phase_cli/cmd/users/logout.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
import keyring
66
from phase_cli.utils.const import PHASE_SECRETS_DIR, CONFIG_FILE
77
from phase_cli.utils.misc import get_default_account_id
8+
from rich.console import Console
89

10+
console = Console(stderr=True)
911
def save_config(config_data):
1012
"""Saves the updated configuration data to the config file."""
1113
with open(CONFIG_FILE, 'w') as f:
@@ -24,16 +26,16 @@ def phase_cli_logout(purge=False):
2426
# Delete PHASE_SECRETS_DIR if it exists
2527
if os.path.exists(PHASE_SECRETS_DIR):
2628
shutil.rmtree(PHASE_SECRETS_DIR)
27-
print("Logged out and purged all local data.")
29+
console.print("Logged out and purged all local data.")
2830
else:
29-
print("No local data found to purge.")
31+
console.print("No local data found to purge.")
3032
except ValueError as e:
31-
print(e)
33+
console.log(f"Error: {e}")
3234
sys.exit(1)
3335
else:
3436
# Load the existing config to update it
3537
if not os.path.exists(config_file_path):
36-
print("No configuration found. Please run 'phase auth' to set up your configuration.")
38+
console.log("Error: No configuration found. Please run 'phase auth' to set up your configuration.")
3739
sys.exit(1)
3840

3941
with open(config_file_path, 'r') as f:
@@ -53,6 +55,6 @@ def phase_cli_logout(purge=False):
5355
config_data['default-user'] = config_data['phase-users'][0]['id']
5456

5557
save_config(config_data)
56-
print("Logged out successfully.")
58+
console.print("Logged out successfully.")
5759
else:
58-
print("No default user in configuration found. Please run 'phase auth' to set up your configuration.")
60+
console.log("Error: No default user in configuration found. Please run 'phase auth' to set up your configuration.")

phase_cli/main.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from phase_cli.cmd.run import phase_run_inject
1515
from phase_cli.cmd.shell import phase_shell
1616
from phase_cli.cmd.init import phase_init
17-
from phase_cli.cmd.auth import phase_auth
17+
from phase_cli.cmd.auth.auth import phase_auth
1818
from phase_cli.cmd.secrets.list import phase_list_secrets
1919
from phase_cli.cmd.secrets.get import phase_secrets_get
2020
from phase_cli.cmd.secrets.export import phase_secrets_env_export
@@ -107,7 +107,10 @@ def main ():
107107

108108
# Auth command
109109
auth_parser = subparsers.add_parser('auth', help='💻 Authenticate with Phase')
110-
auth_parser.add_argument('--mode', choices=['token', 'webauth'], default='webauth', help='Mode of authentication. Default: webauth')
110+
auth_parser.add_argument('--mode', choices=['token', 'webauth', 'aws-iam'], default='webauth', help='Mode of authentication. Default: webauth')
111+
auth_parser.add_argument('--service-account-id', type=str, help='Service Account ID for when using external identities for authentication.')
112+
auth_parser.add_argument('--ttl', type=int, help='Token TTL in seconds for tokens created using external identities.')
113+
auth_parser.add_argument('--no-store', action='store_true', help='For external identity modes (e.g., aws-iam): print authentication token response to stdout without storing credentials or setting a default user.')
111114

112115
# Init command
113116
init_parser = subparsers.add_parser('init', help='🔗 Link your project with your Phase app')
@@ -359,10 +362,18 @@ def main ():
359362
if sys.platform == "linux":
360363
update_parser = subparsers.add_parser('update', help='🆙 Update the Phase CLI to the latest version')
361364

365+
# If no arguments are provided, show top-level help and exit successfully (code 0)
366+
# TODO: This is a temporary fix to ensure the CLI exits successfully when no arguments are provided.
367+
if len(sys.argv) == 1:
368+
print(description)
369+
print(phaseASCii)
370+
parser.print_help()
371+
sys.exit(0)
372+
362373
args = parser.parse_args()
363374

364375
if args.command == 'auth':
365-
phase_auth(args.mode)
376+
phase_auth(args.mode, service_account_id=args.service_account_id, ttl=args.ttl, no_store=args.no_store)
366377
sys.exit(0)
367378
elif args.command == 'init':
368379
phase_init()

phase_cli/utils/const.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import os
22
import re
33

4-
__version__ = "1.20.0"
4+
__version__ = "1.21.0"
55
__ph_version__ = "v1"
66

77
description = (
@@ -33,8 +33,12 @@
3333
PHASE_SECRETS_DIR, "config.json"
3434
) # Holds local user account configurations
3535

36+
3637
PHASE_CLOUD_API_HOST = "https://console.phase.dev"
37-
PHASE_CLOUD_PUBLIC_API_HOST = "https://api.phase.dev"
38+
39+
# AWS Config
40+
AWS_DEFAULT_GLOBAL_STS_ENDPOINT = "https://sts.amazonaws.com"
41+
AWS_DEFAULT_GLOBAL_STS_REGION = "us-east-1"
3842

3943
pss_user_pattern = re.compile(
4044
r"^pss_user:v(\d+):([a-fA-F0-9]{64}):([a-fA-F0-9]{64}):([a-fA-F0-9]{64}):([a-fA-F0-9]{64})$"

0 commit comments

Comments
 (0)