Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
2c6a33a
refactor: rename get_default_user_id to get_default_account_id and ad…
nimish-ks Aug 26, 2025
adce991
refactor: update keyring service name to use account ID
nimish-ks Aug 26, 2025
598005f
fix: handle missing email for default user in whoami command
nimish-ks Aug 26, 2025
a9910e0
refactor: update user switch functionality for consistency
nimish-ks Aug 26, 2025
609c5af
refactor: replace user ID references with account ID in logout functi…
nimish-ks Aug 26, 2025
becfc85
refactor: remove unused user ID reference in import_env.py
nimish-ks Aug 26, 2025
66271cc
refactor: enhance token-based authentication flow
nimish-ks Aug 26, 2025
959fdaf
refactor: enhance authentication flow for Personal Access Tokens
nimish-ks Aug 26, 2025
fa6ed7c
chore(deps): add boto3 and botocore dependencies
nimish-ks Aug 26, 2025
cc2c9cf
feat(auth): implement web-based and token-based authentication
nimish-ks Aug 26, 2025
82e6f8e
refactor: remove print_phase_links function from misc.py
nimish-ks Aug 26, 2025
1b5bd8e
feat(auth): add AWS IAM authentication module
nimish-ks Aug 26, 2025
1625772
feat(auth): enhance authentication options with AWS IAM support
nimish-ks Aug 26, 2025
8b797cd
chore(deps): update botocore version in requirements.txt
nimish-ks Aug 26, 2025
2817943
refactor(auth): streamline AWS session handling in authentication module
nimish-ks Aug 26, 2025
4b910b6
refactor(auth): simplify region and endpoint resolution in AWS authen…
nimish-ks Aug 26, 2025
31e7f13
feat(auth): add optional TTL parameter for AWS IAM authentication
nimish-ks Aug 26, 2025
76110b2
refactor(logout): replace print statements with rich console output
nimish-ks Aug 26, 2025
795b819
feat(auth): update authentication command to support external identit…
nimish-ks Aug 26, 2025
f97b535
Merge branch 'main' into feat--phase-auth-svc-account
nimish-ks Aug 31, 2025
01bd79d
Merge branch 'feat--phase-auth-svc-account' into feat--phase-auth-aws…
nimish-ks Aug 31, 2025
0993ea0
feat(auth): add no-login option to phase_auth function
nimish-ks Sep 8, 2025
711b1c9
feat(auth): enhance authentication command with no-login option
nimish-ks Sep 8, 2025
8f5a4d5
chore: bump version to 1.20.0 in APKBUILD and const.py
nimish-ks Sep 8, 2025
e1d1b0a
feat(auth): rename no-login option to no-store for clarity
nimish-ks Sep 8, 2025
8ae3255
Merge remote-tracking branch 'origin/main' into feat--phase-auth-aws-iam
nimish-ks Oct 2, 2025
b08dc1a
chore: reset changes from bad rebase
nimish-ks Oct 2, 2025
84ddda6
feat: use consistent routing pattern
nimish-ks Oct 2, 2025
9328df1
chore: remove phase cloud / self-hosted host switcher
nimish-ks Oct 2, 2025
4ec0f29
feat: add AWS IAM authentication support to Phase API
nimish-ks Oct 2, 2025
c8be980
refactor: streamline AWS IAM authentication flow
nimish-ks Oct 2, 2025
cd68bdd
fix: update parameter naming in AWS IAM authentication call
nimish-ks Oct 2, 2025
3727ca5
chore: bump version to 1.21.0 in APKBUILD and const.py
nimish-ks Oct 2, 2025
ddc2458
fix: remove unused import of PHASE_CLOUD_PUBLIC_API_HOST from misc.py
nimish-ks Oct 2, 2025
2bcd95b
feat: add AWS configuration constants for STS endpoint and region
nimish-ks Oct 2, 2025
a1eb0eb
refactor: replace hardcoded STS endpoint and region with constants
nimish-ks Oct 2, 2025
7127977
fix: update AWS IAM authentication URL for external identities
nimish-ks Oct 2, 2025
57b683f
fix: add trailing slash
rohan-chaturvedi Oct 3, 2025
aafb2ff
fix: ensure CLI exits successfully with no arguments
nimish-ks Oct 4, 2025
d02dddf
fix: improve error handling in phase_auth function
nimish-ks Oct 4, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion APKBUILD
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Maintainer: Phase <info@phase.dev>
pkgname=phase
pkgver=1.20.0
pkgver=1.21.0
pkgrel=0
pkgdesc="Phase CLI"
url="https://phase.dev"
Expand Down
Empty file added phase_cli/cmd/auth/__init__.py
Empty file.
89 changes: 78 additions & 11 deletions phase_cli/cmd/auth.py → phase_cli/cmd/auth/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@
import base64
import time, random
import questionary
from phase_cli.utils.misc import open_browser, validate_url, print_phase_links
from phase_cli.utils.misc import open_browser, validate_url
from phase_cli.utils.crypto import CryptoUtils
from phase_cli.utils.phase_io import Phase
from phase_cli.utils.const import PHASE_SECRETS_DIR, PHASE_CLOUD_API_HOST
from phase_cli.cmd.auth.aws import perform_aws_iam_auth
from rich.console import Console

class SimpleHTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
Expand Down Expand Up @@ -93,9 +94,9 @@ def do_POST(self):
return httpd


def phase_auth(mode="webauth"):
def phase_auth(mode="webauth", service_account_id=None, ttl=None, no_store=False):
"""
Handles authentication for the Phase CLI using either web-based or token-based authentication.
Handles authentication for the Phase CLI using web-based, token-based, or AWS IAM authentication.

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.

Expand All @@ -114,8 +115,16 @@ def phase_auth(mode="webauth"):
- Asks the user for their email and personal access token.
- Validates the credentials and writes them to the keyring.

For aws-iam:
- Uses AWS IAM credentials to authenticate with Phase.
- Requires a service account ID to be provided.
- Signs an AWS STS GetCallerIdentity request and sends it to Phase for verification.
- Receives a Phase token in response and stores it in the keyring.

Args:
- mode (str): The mode of authentication to use. Default is "webauth". Can be either "webauth" or "token".
- mode (str): The mode of authentication to use. Default is "webauth". Can be either "webauth", "token", or "aws-iam".
- service_account_id (str): Required for aws-iam mode. The service account ID to authenticate with.
- ttl (int): Optional for aws-iam mode. Token TTL in seconds.

Returns:
None
Expand All @@ -125,8 +134,66 @@ def phase_auth(mode="webauth"):

server = None
try:
# Choose the authentication mode: webauth (default) or token-based.
if mode == 'token':
# Choose the authentication mode: webauth (default), token-based, or aws-iam.
if mode == 'aws-iam':
# AWS IAM authentication
if not service_account_id:
console.log("Error: --service-account-id is required when using --mode aws-iam")
sys.exit(2)

# Check if PHASE_HOST environment variable is set for headless operation
PHASE_API_HOST = os.getenv("PHASE_HOST")

if PHASE_API_HOST:
console.log(f"Using PHASE_HOST environment variable: {PHASE_API_HOST}")
else:
# Interactive mode: ask user to choose instance type
phase_instance_type = questionary.select(
'Choose your Phase instance type:',
choices=['☁️ Phase Cloud', '🛠️ Self Hosted']
).ask()

if not phase_instance_type:
console.log("\nExiting phase...")
return

if phase_instance_type == '🛠️ Self Hosted':
PHASE_API_HOST = questionary.text("Please enter your host (URL eg. https://example.com/path):").ask()
if not PHASE_API_HOST:
console.log("\nExiting phase...")
return
else:
PHASE_API_HOST = PHASE_CLOUD_API_HOST

# Perform AWS IAM authentication
try:
console.log("Authenticating with AWS IAM credentials...")
aws_result = perform_aws_iam_auth(host=PHASE_API_HOST, service_account_id=service_account_id, ttl=ttl)

# Extract the token from the AWS auth response
auth_data = aws_result.get("authentication", {})
auth_token = auth_data.get("token")

if not auth_token:
raise ValueError("No token received from AWS IAM authentication")

console.log("AWS IAM authentication successful")

# If user requested no-store, print raw result and exit early
if no_store:
print(json.dumps(aws_result, indent=4))
return

# Validate the token with Phase API by initializing Phase client
phase = Phase(init=False, pss=auth_token, host=PHASE_API_HOST)
result = phase.auth()
user_email = None # Service accounts don't have emails

except Exception as e:
console.log(f"AWS IAM authentication failed: {e}")
return

elif mode == 'token':
# Manual token-based authentication
# Check if PHASE_HOST environment variable is set for headless operation
PHASE_API_HOST = os.getenv("PHASE_HOST")
Expand All @@ -148,6 +215,7 @@ def phase_auth(mode="webauth"):
PHASE_API_HOST = questionary.text("Please enter your host (URL eg. https://example.com/path):").ask()
if not PHASE_API_HOST:
console.log("\nExiting phase...")
sys.exit(2)
return
else:
PHASE_API_HOST = PHASE_CLOUD_API_HOST
Expand Down Expand Up @@ -206,6 +274,7 @@ def phase_auth(mode="webauth"):

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

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

if token_saved_in_keyring:
print("\033[1;32m✅ Authentication successful.\033[0m")
console.print("[bold green]✅ Authentication successful.[/bold green]")
else:
print("\033[1;32m✅ Authentication successful.\033[0m")
print("\033[1;36m🎉 Welcome to Phase CLI!\033[0m\n")
print_phase_links()
console.print("[bold green]✅ Authentication successful.[/bold green]")

else:
console.log("Failed to authenticate with the provided credentials.")
Expand All @@ -321,4 +388,4 @@ def phase_auth(mode="webauth"):
sys.exit(1)
finally:
if server:
server.shutdown()
server.shutdown()
68 changes: 68 additions & 0 deletions phase_cli/cmd/auth/aws.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
from botocore.awsrequest import AWSRequest
from botocore.auth import SigV4Auth
from botocore.credentials import get_credentials
from botocore.session import get_session
from botocore.config import Config
from phase_cli.utils.network import external_identity_auth_aws
from phase_cli.utils.const import AWS_DEFAULT_GLOBAL_STS_REGION, AWS_DEFAULT_GLOBAL_STS_ENDPOINT


def resolve_region_and_endpoint() -> tuple[str, str]:
session = get_session()
aws_region = session.get_config_variable('region')
if not aws_region:
try:
client_config = Config(region_name=None)
session.create_client('sts', config=client_config)
aws_region = session.get_config_variable('region')
except Exception:
pass

if aws_region:
return aws_region, f"https://sts.{aws_region}.amazonaws.com"

# Fallback to legacy global endpoint, sign with us-east-1
return AWS_DEFAULT_GLOBAL_STS_REGION, AWS_DEFAULT_GLOBAL_STS_ENDPOINT


def sign_get_caller_identity(region: str, endpoint: str, method: str = "POST") -> tuple[str, dict, str]:
"""
Returns (signed_url, signed_headers, body) for GetCallerIdentity.
Uses header-based SigV4 (includes X-Amz-Date header).
"""
# STS Query API (Action=GetCallerIdentity&Version=2011-06-15)
body = "Action=GetCallerIdentity&Version=2011-06-15"
headers = {"Content-Type": "application/x-www-form-urlencoded; charset=utf-8"}

session = get_session()
creds = session.get_credentials()
if creds is None:
raise SystemExit("No AWS credentials found. On EC2, attach an instance profile or set AWS_* env vars.")

frozen = creds.get_frozen_credentials()
req = AWSRequest(method=method, url=endpoint, data=body, headers=headers)
SigV4Auth(frozen, "sts", region).add_auth(req)
prepared = req.prepare()

signed_url = prepared.url
signed_headers = dict(prepared.headers.items())
return signed_url, signed_headers, body


def perform_aws_iam_auth(host: str, service_account_id: str, ttl: int | None = None, method: str = "POST"):
"""
Perform complete AWS IAM authentication flow with Phase.

Args:
host: Phase API base URL
service_account_id: Service Account ID to authenticate (UUID)
ttl: Requested token TTL in seconds (optional)
method: HTTP method to sign (default: POST)

Returns:
dict: Authentication response from Phase API containing token
"""
region, endpoint = resolve_region_and_endpoint()
signed = sign_get_caller_identity(region=region, endpoint=endpoint, method=method)
result = external_identity_auth_aws(host, service_account_id, ttl, signed, method=method)
return result
14 changes: 8 additions & 6 deletions phase_cli/cmd/users/logout.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
import keyring
from phase_cli.utils.const import PHASE_SECRETS_DIR, CONFIG_FILE
from phase_cli.utils.misc import get_default_account_id
from rich.console import Console

console = Console(stderr=True)
def save_config(config_data):
"""Saves the updated configuration data to the config file."""
with open(CONFIG_FILE, 'w') as f:
Expand All @@ -24,16 +26,16 @@ def phase_cli_logout(purge=False):
# Delete PHASE_SECRETS_DIR if it exists
if os.path.exists(PHASE_SECRETS_DIR):
shutil.rmtree(PHASE_SECRETS_DIR)
print("Logged out and purged all local data.")
console.print("Logged out and purged all local data.")
else:
print("No local data found to purge.")
console.print("No local data found to purge.")
except ValueError as e:
print(e)
console.log(f"Error: {e}")
sys.exit(1)
else:
# Load the existing config to update it
if not os.path.exists(config_file_path):
print("No configuration found. Please run 'phase auth' to set up your configuration.")
console.log("Error: No configuration found. Please run 'phase auth' to set up your configuration.")
sys.exit(1)

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

save_config(config_data)
print("Logged out successfully.")
console.print("Logged out successfully.")
else:
print("No default user in configuration found. Please run 'phase auth' to set up your configuration.")
console.log("Error: No default user in configuration found. Please run 'phase auth' to set up your configuration.")
17 changes: 14 additions & 3 deletions phase_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from phase_cli.cmd.run import phase_run_inject
from phase_cli.cmd.shell import phase_shell
from phase_cli.cmd.init import phase_init
from phase_cli.cmd.auth import phase_auth
from phase_cli.cmd.auth.auth import phase_auth
from phase_cli.cmd.secrets.list import phase_list_secrets
from phase_cli.cmd.secrets.get import phase_secrets_get
from phase_cli.cmd.secrets.export import phase_secrets_env_export
Expand Down Expand Up @@ -107,7 +107,10 @@ def main ():

# Auth command
auth_parser = subparsers.add_parser('auth', help='💻 Authenticate with Phase')
auth_parser.add_argument('--mode', choices=['token', 'webauth'], default='webauth', help='Mode of authentication. Default: webauth')
auth_parser.add_argument('--mode', choices=['token', 'webauth', 'aws-iam'], default='webauth', help='Mode of authentication. Default: webauth')
auth_parser.add_argument('--service-account-id', type=str, help='Service Account ID for when using external identities for authentication.')
auth_parser.add_argument('--ttl', type=int, help='Token TTL in seconds for tokens created using external identities.')
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.')

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

# If no arguments are provided, show top-level help and exit successfully (code 0)
# TODO: This is a temporary fix to ensure the CLI exits successfully when no arguments are provided.
if len(sys.argv) == 1:
print(description)
print(phaseASCii)
parser.print_help()
sys.exit(0)

args = parser.parse_args()

if args.command == 'auth':
phase_auth(args.mode)
phase_auth(args.mode, service_account_id=args.service_account_id, ttl=args.ttl, no_store=args.no_store)
sys.exit(0)
elif args.command == 'init':
phase_init()
Expand Down
8 changes: 6 additions & 2 deletions phase_cli/utils/const.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import os
import re

__version__ = "1.20.0"
__version__ = "1.21.0"
__ph_version__ = "v1"

description = (
Expand Down Expand Up @@ -33,8 +33,12 @@
PHASE_SECRETS_DIR, "config.json"
) # Holds local user account configurations


PHASE_CLOUD_API_HOST = "https://console.phase.dev"
PHASE_CLOUD_PUBLIC_API_HOST = "https://api.phase.dev"

# AWS Config
AWS_DEFAULT_GLOBAL_STS_ENDPOINT = "https://sts.amazonaws.com"
AWS_DEFAULT_GLOBAL_STS_REGION = "us-east-1"

pss_user_pattern = re.compile(
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})$"
Expand Down
Loading
Loading