From e7c887484004a3d6619176e7c4bfafa780d2858b Mon Sep 17 00:00:00 2001 From: Chi Thang Duong Date: Thu, 13 Nov 2025 17:22:10 +0100 Subject: [PATCH 1/2] Allow autheticating for multiple regions using the same HTTP callback server --- src/oci_cli/cli_session.py | 72 ++++- src/oci_cli/cli_setup_bootstrap.py | 404 +++++++++++++++++++++++------ 2 files changed, 392 insertions(+), 84 deletions(-) diff --git a/src/oci_cli/cli_session.py b/src/oci_cli/cli_session.py index 859485976..8cfc7c404 100644 --- a/src/oci_cli/cli_session.py +++ b/src/oci_cli/cli_session.py @@ -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): diff --git a/src/oci_cli/cli_setup_bootstrap.py b/src/oci_cli/cli_setup_bootstrap.py index 011c535c5..a0e2d8959 100644 --- a/src/oci_cli/cli_setup_bootstrap.py +++ b/src/oci_cli/cli_setup_bootstrap.py @@ -6,7 +6,11 @@ from oci_cli import cli_setup from oci_cli import cli_util -from oci_cli.cli_setup import DEFAULT_KEY_NAME, PUBLIC_KEY_FILENAME_SUFFIX, PRIVATE_KEY_FILENAME_SUFFIX +from oci_cli.cli_setup import ( + DEFAULT_KEY_NAME, + PUBLIC_KEY_FILENAME_SUFFIX, + PRIVATE_KEY_FILENAME_SUFFIX, +) from oci_cli.cli_setup import prompt_for_passphrase import base64 @@ -18,28 +22,34 @@ import sys import uuid import webbrowser +import time from oci import identity from urllib.parse import urlparse, parse_qs, urlencode from http.server import BaseHTTPRequestHandler, HTTPServer BOOTSTRAP_SERVICE_PORT = 8181 -BOOTSTRAP_PROCESS_CANCELED_MESSAGE = 'Bootstrap process canceled.' +BOOTSTRAP_PROCESS_CANCELED_MESSAGE = "Bootstrap process canceled." CONSOLE_AUTH_URL_FORMAT = "https://login.{region}.{realm}/v1/oauth2/authorize" -@cli_setup.setup_group.command('bootstrap', help=""" +@cli_setup.setup_group.command( + "bootstrap", + help=""" Provides an interactive process to create a CLI config file using username / password based login through a browser. Also handles generating API keys and uploading them to your Oracle Cloud Infrastructure account. -Note that port {port} must be available in order for this command to complete properly.""".format(port=BOOTSTRAP_SERVICE_PORT)) -@click.option('--profile-name', help='Name of the profile you are creating') -@click.option('--config-location', help='Path to the config for the new profile') +Note that port {port} must be available in order for this command to complete properly.""".format( + port=BOOTSTRAP_SERVICE_PORT + ), +) +@click.option("--profile-name", help="Name of the profile you are creating") +@click.option("--config-location", help="Path to the config for the new profile") @cli_util.help_option @click.pass_context @cli_util.wrap_exceptions def bootstrap_oci_cli(ctx, profile_name, config_location): - region_param = ctx.obj['region'] if ctx.obj['region'] else '' + region_param = ctx.obj["region"] if ctx.obj["region"] else "" user_session = create_user_session(region=region_param) public_key = user_session.public_key @@ -52,7 +62,7 @@ def bootstrap_oci_cli(ctx, profile_name, config_location): # create initial SDK client which targets region that user specified signer = oci.auth.signers.SecurityTokenSigner(token, private_key) - client = identity.IdentityClient({'region': region}, signer=signer) + client = identity.IdentityClient({"region": region}, signer=signer) # find home region and create new client targeting home region to use for subsequent identity requests result = client.list_region_subscriptions(tenancy_ocid) @@ -61,66 +71,84 @@ def bootstrap_oci_cli(ctx, profile_name, config_location): home_region = r.region_name break - client = identity.IdentityClient({'region': home_region}, signer=signer) + client = identity.IdentityClient({"region": home_region}, signer=signer) create_api_key_details = identity.models.CreateApiKeyDetails() - create_api_key_details.key = cli_util.serialize_key(public_key=public_key).decode('UTF-8') + create_api_key_details.key = cli_util.serialize_key(public_key=public_key).decode( + "UTF-8" + ) try: result = client.upload_api_key(user_ocid, create_api_key_details) except oci.exceptions.ServiceError as e: - if e.status == 409 and e.code == 'ApiKeyLimitExceeded': + if e.status == 409 and e.code == "ApiKeyLimitExceeded": # User cannot upload any more API keys, so ask if they'd like to delete one result = client.list_api_keys(user_ocid) - click.echo('ApiKey limit has been reached for this user account.') - click.echo('The following API keys are currently enabled for this account:') + click.echo("ApiKey limit has been reached for this user account.") + click.echo("The following API keys are currently enabled for this account:") count = 1 for result in result.data: - click.echo('\tKey [{index}]: Fingerprint: {fingerprint}, Time Created: {time_created}'.format( - index=count, - fingerprint=result.fingerprint, - time_created=result.time_created - ), sys.stderr) + click.echo( + "\tKey [{index}]: Fingerprint: {fingerprint}, Time Created: {time_created}".format( + index=count, + fingerprint=result.fingerprint, + time_created=result.time_created, + ), + sys.stderr, + ) count += 1 - delete_thumbprint = click.prompt(text='Enter the fingerprint of the API key to delete to make space for the new key (leave empty to skip deletion and exit command)', confirmation_prompt=True) + delete_thumbprint = click.prompt( + text="Enter the fingerprint of the API key to delete to make space for the new key (leave empty to skip deletion and exit command)", + confirmation_prompt=True, + ) if not delete_thumbprint: click.echo(BOOTSTRAP_PROCESS_CANCELED_MESSAGE) sys.exit(0) client.delete_api_key(user_ocid, delete_thumbprint) - click.echo('Deleted Api key with fingerprint: {}'.format(delete_thumbprint)) + click.echo("Deleted Api key with fingerprint: {}".format(delete_thumbprint)) client.upload_api_key(user_ocid, create_api_key_details) else: raise e - click.echo('Uploaded new API key with fingerprint: {}'.format(fingerprint)) + click.echo("Uploaded new API key with fingerprint: {}".format(fingerprint)) # write credentials to filesystem config_loc = os.path.expanduser(config_location) if config_location else None - profile_name, config_location = persist_user_session(user_session, profile_name=profile_name, config=config_loc, persist_token=False, bootstrap=True) + profile_name, config_location = persist_user_session( + user_session, + profile_name=profile_name, + config=config_loc, + persist_token=False, + bootstrap=True, + ) - click.echo('Config written to: {}'.format(config_location)) + click.echo("Config written to: {}".format(config_location)) - click.echo(""" + click.echo( + """ Try out your newly registered credentials with the following example command: oci iam region list --config-file {config_file} --profile {profile} -""".format(config_file=config_location, profile=profile_name)) +""".format(config_file=config_location, profile=profile_name) + ) -def create_user_session(region='', tenancy_name=None): - if region == '': +def create_user_session(region="", tenancy_name=None): + if region == "": region = cli_setup.prompt_for_region() # try to set up http server so we can fail early if the required port is in use try: - server_address = ('', BOOTSTRAP_SERVICE_PORT) + 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) + click.echo( + "Could not complete bootstrap process because port {port} is already in use.".format( + port=BOOTSTRAP_SERVICE_PORT + ) ) sys.exit(1) @@ -136,69 +164,210 @@ def create_user_session(region='', tenancy_name=None): key = cli_util.to_jwk(public_key) jwk_content = key - bytes_jwk_content = jwk_content.encode('UTF-8') - b64_jwk_content = base64.urlsafe_b64encode(bytes_jwk_content).decode('UTF-8') + bytes_jwk_content = jwk_content.encode("UTF-8") + b64_jwk_content = base64.urlsafe_b64encode(bytes_jwk_content).decode("UTF-8") public_key_jwk = b64_jwk_content 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) + "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 + query["tenant"] = tenancy_name 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]]) + 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)) + click.echo( + "Error: {} is not a valid region. Valid regions are \n{}".format( + region, regions.REGIONS + ) + ) sys.exit(1) query_string = urlencode(query) url = "{console_auth_url}?{query_string}".format( - console_auth_url=console_url, - query_string=query_string + console_auth_url=console_url, query_string=query_string ) # attempt to open browser to console log in page try: if webbrowser.open_new(url): - click.echo(' Please switch to newly opened browser window to log in!') - click.echo(' You can also open the following URL in a web browser window to continue:') - click.echo('%s' % url) + click.echo(" Please switch to newly opened browser window to log in!") + click.echo( + " You can also open the following URL in a web browser window to continue:" + ) + click.echo("%s" % url) else: - click.echo(' Open the following URL in a web browser window to continue:') - click.echo('%s' % url) + click.echo( + " Open the following URL in a web browser window to continue:" + ) + click.echo("%s" % url) except webbrowser.Error as e: - click.echo('Could not launch web browser to complete login process, exiting bootstrap command. Error: {exc_info}.'.format( - exc_info=str(e) - )) + click.echo( + "Could not launch web browser to complete login process, exiting bootstrap command. Error: {exc_info}.".format( + exc_info=str(e) + ) + ) sys.exit(1) # start up http server which will handle capturing auth redirect from console token = httpd.serve_forever() - click.echo(' Completed browser authentication process!') + click.echo(" Completed browser authentication process!") # get user / tenant info out of token - security_token_container = oci.auth.security_token_container.SecurityTokenContainer(None, security_token=token) + security_token_container = oci.auth.security_token_container.SecurityTokenContainer( + None, security_token=token + ) token_data = security_token_container.get_jwt() - user_ocid = token_data['sub'] - tenancy_ocid = token_data['tenant'] + user_ocid = token_data["sub"] + tenancy_ocid = token_data["tenant"] + + 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 UserSession(user_ocid, tenancy_ocid, region, token, public_key, private_key, fingerprint) + 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): +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 config_location, profile_name = cli_setup.prompt_session_for_profile() @@ -212,14 +381,22 @@ def persist_user_session(user_session, profile_name=None, config=None, use_passp sys.exit(0) # prompt for directory to place keys - session_auth_location = os.path.abspath(os.path.join(cli_setup.DEFAULT_TOKEN_DIRECTORY, profile_name)) + session_auth_location = os.path.abspath( + os.path.join(cli_setup.DEFAULT_TOKEN_DIRECTORY, profile_name) + ) if not os.path.exists(session_auth_location): cli_util.create_directory(session_auth_location) - public_key_file_path = os.path.join(session_auth_location, DEFAULT_KEY_NAME + PUBLIC_KEY_FILENAME_SUFFIX) + public_key_file_path = os.path.join( + session_auth_location, DEFAULT_KEY_NAME + PUBLIC_KEY_FILENAME_SUFFIX + ) if not persist_only_public_key: - private_key_file_path = os.path.join(session_auth_location, DEFAULT_KEY_NAME + PRIVATE_KEY_FILENAME_SUFFIX) - if not cli_setup.write_public_key_to_file(public_key_file_path, user_session.public_key, True, True): + private_key_file_path = os.path.join( + session_auth_location, DEFAULT_KEY_NAME + PRIVATE_KEY_FILENAME_SUFFIX + ) + if not cli_setup.write_public_key_to_file( + public_key_file_path, user_session.public_key, True, True + ): click.echo(BOOTSTRAP_PROCESS_CANCELED_MESSAGE) sys.exit(0) @@ -228,14 +405,16 @@ def persist_user_session(user_session, profile_name=None, config=None, use_passp key_passphrase = prompt_for_passphrase() if not persist_only_public_key: - if not cli_setup.write_private_key_to_file(private_key_file_path, user_session.private_key, key_passphrase, True, True): + if not cli_setup.write_private_key_to_file( + private_key_file_path, user_session.private_key, key_passphrase, True, True + ): click.echo(BOOTSTRAP_PROCESS_CANCELED_MESSAGE) sys.exit(0) # write token to a file so we can refresh it without having to read / write the entire config if persist_token: - token_location = os.path.join(session_auth_location, 'token') - with open(token_location, 'w') as security_token_file: + token_location = os.path.join(session_auth_location, "token") + with open(token_location, "w") as security_token_file: security_token_file.write(user_session.token) cli_util.apply_user_only_access_permissions(token_location) @@ -246,25 +425,34 @@ def persist_user_session(user_session, profile_name=None, config=None, use_passp if bootstrap: userId = user_session.user_ocid - if session_auth and key_passphrase and not click.confirm('Do you want to write your passphrase to the config file? (If not, you will need to enter it when prompted each time you run an oci command)', default=False): + if ( + session_auth + and key_passphrase + and not click.confirm( + "Do you want to write your passphrase to the config file? (If not, you will need to enter it when prompted each time you run an oci command)", + default=False, + ) + ): key_passphrase = None cli_setup.write_config( filename=config_location, user_id=userId, fingerprint=user_session.fingerprint, - key_file=os.path.abspath(private_key_file_path) if not persist_only_public_key else "Update_private_key_path", + key_file=os.path.abspath(private_key_file_path) + if not persist_only_public_key + else "Update_private_key_path", tenancy=user_session.tenancy_ocid, region=user_session.region, pass_phrase=key_passphrase, profile_name=profile_name, - security_token_file=token_location if persist_token else None + security_token_file=token_location if persist_token else None, ) return profile_name, config_location -class StoppableHttpRequestHandler (BaseHTTPRequestHandler): +class StoppableHttpRequestHandler(BaseHTTPRequestHandler): """http request handler with abilitiy to stop the server""" def log_message(self, format, *args): @@ -276,7 +464,7 @@ def do_GET(self): self.send_response(200) self.end_headers() - if self.path == '/': + if self.path == "/": javascript = """