Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
72 changes: 70 additions & 2 deletions src/oci_cli/cli_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,81 @@ def session_group():
@click.option('--profile-name', help='Name of the profile you are creating')
@click.option('--config-location', help='Path to the config for the new session')
@click.option('--use-passphrase', is_flag=True, help='Provide a passphrase to be used to encrypt the private key from the generated key pair')
@click.option('--regions', help='Comma-separated list of regions to authenticate; may span realms')
@cli_util.help_option
@click.pass_context
@cli_util.wrap_exceptions
def authenticate(ctx, region, tenancy_name, profile_name, config_location, use_passphrase, no_browser, public_key_file_path, session_expiration_in_minutes, token_location):
def authenticate(ctx, region, tenancy_name, profile_name, config_location, use_passphrase, no_browser, public_key_file_path, session_expiration_in_minutes, token_location, regions):
region = ctx.obj['region']
if region is None:
if region is None and regions is None:
region = cli_setup.prompt_for_region()
# Multi-realm concurrent browser authentication if --regions provided (and not --no-browser)
if (not no_browser) and regions:
# Parse and normalize regions
raw_regions = [r.strip() for r in regions.split(',') if r.strip()]
if not raw_regions:
click.echo('ERROR: --regions provided but no regions parsed', file=sys.stderr)
sys.exit(1)

normalized_regions = []
for r in raw_regions:
if r in oci.regions.REGIONS_SHORT_NAMES:
r = oci.regions.REGIONS_SHORT_NAMES[r]
if not oci.regions.is_region(r):
click.echo("Error: {} is not a valid region. Valid regions are \n{}".format(r, oci.regions.REGIONS), file=sys.stderr)
sys.exit(1)
normalized_regions.append(r)

# Group regions by realm
regions_by_realm = {}
for r in normalized_regions:
realm_code = oci.regions.REGION_REALMS[r]
regions_by_realm.setdefault(realm_code, []).append(r)

# Choose a primary region per realm (first in list)
realm_to_primary_region = {realm: rlist[0] for realm, rlist in regions_by_realm.items()}

# Drive concurrent multi-realm auth; returns tokens mapped by realm
public_key, private_key, fingerprint, tokens_by_realm = cli_setup_bootstrap.create_user_sessions_multi_realm(
realm_to_primary_region, tenancy_name
)

written_profiles = []
config_path = os.path.expanduser(config_location) if config_location else None

# Persist a per-region profile for each realm using the realm's token
for realm_code, token in tokens_by_realm.items():
# Parse user and tenancy from token
stc = oci.auth.security_token_container.SecurityTokenContainer(None, security_token=token)
token_data = stc.get_jwt()
user_ocid = token_data['sub']
tenancy_ocid = token_data['tenant']

for r in regions_by_realm.get(realm_code, []):
session = cli_setup_bootstrap.UserSession(user_ocid, tenancy_ocid, r, token, public_key, private_key, fingerprint)
_, config_path = cli_setup_bootstrap.persist_user_session(
session,
profile_name=realm_code.upper(),
config=config_location,
use_passphrase=use_passphrase,
persist_token=True,
session_auth=True,
persist_only_public_key=False
)
written_profiles.append((r, config_path))

# Output summary and example usage
if written_profiles:
click.echo('Config written to: {}'.format(written_profiles[0][1]))
created = ', '.join([p for p, _ in written_profiles])
click.echo('Created profiles: {}'.format(created))
click.echo("""
Try out your newly created session credentials with the following example command:

oci iam region list --config-file {config_file} --profile {profile} --auth {auth}
""".format(config_file=written_profiles[0][1], profile=written_profiles[0][0], auth=cli_constants.OCI_CLI_AUTH_SESSION_TOKEN))
return

persist_only_public_key = False
if no_browser:
if int(session_expiration_in_minutes) > int(cli_constants.OCI_CLI_UPST_TOKEN_MAX_TTL):
Expand Down
170 changes: 165 additions & 5 deletions src/oci_cli/cli_setup_bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import sys
import uuid
import webbrowser
import time
from oci import identity

from urllib.parse import urlparse, parse_qs, urlencode
Expand Down Expand Up @@ -198,6 +199,124 @@ def create_user_session(region='', tenancy_name=None):
return UserSession(user_ocid, tenancy_ocid, region, token, public_key, private_key, fingerprint)


def create_user_sessions_multi_realm(realm_to_primary_region, tenancy_name=None, timeout_seconds=600):
"""
Concurrent multi-realm browser authentication.
- realm_to_primary_region: dict mapping realm code (e.g., 'oc1', 'oc8') to a primary region in that realm.
- tenancy_name: optional tenancy short name to pass to Console for login scoping.
- timeout_seconds: total time to wait for all realm callbacks.
Returns: (public_key, private_key, fingerprint, tokens_by_realm) where tokens_by_realm maps realm code -> UPST token.
"""
# try to set up http server so we can fail early if the required port is in use
try:
server_address = ("", BOOTSTRAP_SERVICE_PORT)
httpd = StoppableHttpServer(server_address, StoppableHttpRequestHandler)
except OSError as e:
if e.errno == errno.EADDRINUSE:
click.echo(
"Could not complete bootstrap process because port {port} is already in use.".format(
port=BOOTSTRAP_SERVICE_PORT
)
)
sys.exit(1)
raise e

# Generate single RSA keypair and fingerprint (reused for all realms)
private_key = cli_util.generate_key()
public_key = private_key.public_key()
fingerprint = cli_setup.public_key_to_fingerprint(public_key)

# Build base64url JWK for public key
jwk_content = cli_util.to_jwk(public_key)
bytes_jwk_content = jwk_content.encode("UTF-8")
public_key_jwk = base64.urlsafe_b64encode(bytes_jwk_content).decode("UTF-8")

# Prime the server for multi-realm collection
expected_realms = set(realm_to_primary_region.keys())
httpd.expected_realms = expected_realms
httpd.tokens_by_realm = {}
# Make handle_request return periodically so we can enforce overall timeout
httpd.timeout = 1
httpd.deadline = time.time() + timeout_seconds

# Open one authorize URL per realm (concurrently, i.e., back-to-back)
for realm_code, primary_region in realm_to_primary_region.items():
region = primary_region
if region in regions.REGIONS_SHORT_NAMES:
region = regions.REGIONS_SHORT_NAMES[region]

if regions.is_region(region):
console_url = CONSOLE_AUTH_URL_FORMAT.format(
region=region, realm=regions.REALMS[regions.REGION_REALMS[region]]
)
else:
click.echo(
"Error: {} is not a valid region. Valid regions are \n{}".format(
region, regions.REGIONS
)
)
sys.exit(1)

query = {
"action": "login",
"client_id": "iaas_console",
"response_type": "token id_token",
"nonce": uuid.uuid4(),
"scope": "openid",
"public_key": public_key_jwk,
"redirect_uri": "http://localhost:{}".format(BOOTSTRAP_SERVICE_PORT),
}
if tenancy_name:
query["tenant"] = tenancy_name

url = "{console_auth_url}?{query_string}".format(
console_auth_url=console_url, query_string=urlencode(query)
)

# attempt to open browser to console log in page
try:
if webbrowser.open_new(url):
click.echo(
" Opened login for realm {realm} (primary region {region}).".format(
realm=realm_code, region=primary_region
)
)
click.echo(
" If the browser didn't open, copy/paste this URL:\n{url}".format(
url=url
)
)
else:
click.echo(
" Open this URL in a browser to authenticate realm {realm}:\n{url}".format(
realm=realm_code, url=url
)
)
except webbrowser.Error as e:
click.echo(
"Could not launch web browser to complete login process. Error: {exc}".format(
exc=str(e)
)
)
sys.exit(1)

# Collect callbacks until complete or timeout
tokens_by_realm = httpd.serve_forever()

# If timed out or incomplete, error out
missing = set(expected_realms) - set(tokens_by_realm.keys())
if missing:
click.echo(
"Timeout or missing authentication for realms: {missing}".format(
missing=", ".join(sorted(missing))
),
err=True,
)
sys.exit(1)

return public_key, private_key, fingerprint, tokens_by_realm


def persist_user_session(user_session, profile_name=None, config=None, use_passphrase=False, persist_token=False, bootstrap=False, session_auth=False, persist_only_public_key=False):
if not profile_name:
# prompt for location of user config
Expand Down Expand Up @@ -305,26 +424,67 @@ def do_GET(self):
self.wfile.write(bytes(javascript, 'UTF-8'))
else:
query_components = parse_qs(urlparse(self.path).query)
if 'security_token' in query_components:
security_token = query_components['security_token'][0]
self.server.ret_value = security_token
self.server.stop = True
if "security_token" in query_components:
security_token = query_components["security_token"][0]
# Multi-realm mode: if expected_realms is set, decode token and derive realm from tenancy OCID
if hasattr(self.server, "expected_realms") and self.server.expected_realms:
try:
stc = oci.auth.security_token_container.SecurityTokenContainer(None, security_token)
jwt_payload = stc.get_jwt()
tenancy_ocid = jwt_payload.get("tenant")
realm_code = None
if tenancy_ocid and tenancy_ocid.startswith("ocid1.tenancy.") and len(tenancy_ocid.split(".")) >= 3:
# ocid1.tenancy.ocX.. -> third segment is realm code (e.g., oc8)
realm_code = tenancy_ocid.split(".")[2]
if realm_code:
if not hasattr(self.server, "tokens_by_realm") or self.server.tokens_by_realm is None:
self.server.tokens_by_realm = {}
self.server.tokens_by_realm[realm_code] = security_token
# Stop when all expected realms collected
if set(self.server.tokens_by_realm.keys()) >= set(self.server.expected_realms):
self.server.stop = True
else:
# Fallback to single-token mode if we cannot derive realm
self.server.ret_value = security_token
self.server.stop = True
except Exception:
# Fallback to single-token mode on any decode error
self.server.ret_value = security_token
self.server.stop = True
else:
# Single-token legacy mode
self.server.ret_value = security_token
self.server.stop = True


class StoppableHttpServer (HTTPServer):
"""http server that reacts to self.stop flag"""

def serve_forever(self):
"""Handle one request at a time until stopped."""
"""Handle one request at a time until stopped, with optional timeout and multi-realm collection."""

self.stop = False
self.ret_value = None
# tokens_by_realm may be primed by caller for multi-realm mode
if not hasattr(self, "tokens_by_realm"):
self.tokens_by_realm = {}
self.timed_out = False

while not self.stop:
# If caller set a timeout (socketserver respects self.timeout on handle_request)
self.handle_request()
# Optional overall deadline set by caller: self.deadline
if hasattr(self, "deadline") and self.deadline is not None:
if time.time() >= self.deadline:
self.timed_out = True
self.stop = True
break

self.server_close()

# Return tokens_by_realm if multi-realm mode was used, else ret_value
if hasattr(self, "expected_realms") and self.expected_realms:
return self.tokens_by_realm
return self.ret_value


Expand Down