Skip to content

Commit ea6a535

Browse files
authored
Added support for security token authentication (#231)
2 parents 0bec8e7 + 56e115f commit ea6a535

File tree

5 files changed

+382
-10
lines changed

5 files changed

+382
-10
lines changed

ads/common/auth.py

Lines changed: 227 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
# Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/
66

77
import copy
8+
from datetime import datetime
89
import os
910
from dataclasses import dataclass
11+
import time
1012
from typing import Any, Callable, Dict, Optional, Union
1113

1214
import ads.telemetry
@@ -17,11 +19,18 @@
1719
from oci.config import DEFAULT_LOCATION # "~/.oci/config"
1820
from oci.config import DEFAULT_PROFILE # "DEFAULT"
1921

22+
SECURITY_TOKEN_LEFT_TIME = 600
23+
24+
25+
class SecurityTokenError(Exception): # pragma: no cover
26+
pass
27+
2028

2129
class AuthType(str, metaclass=ExtendedEnumMeta):
2230
API_KEY = "api_key"
2331
RESOURCE_PRINCIPAL = "resource_principal"
2432
INSTANCE_PRINCIPAL = "instance_principal"
33+
SECURITY_TOKEN = "security_token"
2534

2635

2736
class SingletonMeta(type):
@@ -140,6 +149,15 @@ def set_auth(
140149
141150
>>> ads.set_auth("instance_principal") # Set instance principal authentication
142151
152+
>>> ads.set_auth("security_token") # Set security token authentication
153+
154+
>>> config = dict(
155+
... region=us-ashburn-1,
156+
... key_file=~/.oci/sessions/DEFAULT/oci_api_key.pem,
157+
... security_token_file=~/.oci/sessions/DEFAULT/token
158+
... )
159+
>>> ads.set_auth("security_token", config=config) # Set security token authentication from provided config
160+
143161
>>> singer = oci.signer.Signer(
144162
... user=ocid1.user.oc1..<unique_ID>,
145163
... fingerprint=<fingerprint>,
@@ -274,6 +292,50 @@ def resource_principal(
274292
return signer_generator(signer_args).create_signer()
275293

276294

295+
def security_token(
296+
oci_config: Union[str, Dict] = os.path.expanduser(DEFAULT_LOCATION),
297+
profile: str = DEFAULT_PROFILE,
298+
client_kwargs: Dict = None,
299+
) -> Dict:
300+
"""
301+
Prepares authentication and extra arguments necessary for creating clients for different OCI services using Security Token.
302+
303+
Parameters
304+
----------
305+
oci_config: Optional[Union[str, Dict]], default is ~/.oci/config
306+
OCI authentication config file location or a dictionary with config attributes.
307+
profile: Optional[str], is DEFAULT_PROFILE, which is 'DEFAULT'
308+
Profile name to select from the config file.
309+
client_kwargs: Optional[Dict], default None
310+
kwargs that are required to instantiate the Client if we need to override the defaults.
311+
312+
Returns
313+
-------
314+
dict
315+
Contains keys - config, signer and client_kwargs.
316+
317+
- The config contains the config loaded from the configuration loaded from `oci_config`.
318+
- The signer contains the signer object created from the security token.
319+
- client_kwargs contains the `client_kwargs` that was passed in as input parameter.
320+
321+
Examples
322+
--------
323+
>>> from ads.common import oci_client as oc
324+
>>> auth = ads.auth.security_token(oci_config="/home/datascience/.oci/config", profile="TEST", client_kwargs={"timeout": 6000})
325+
>>> oc.OCIClientFactory(**auth).object_storage # Creates Object storage client with timeout set to 6000 using Security Token authentication
326+
"""
327+
signer_args = dict(
328+
oci_config=oci_config if isinstance(oci_config, Dict) else {},
329+
oci_config_location=oci_config
330+
if isinstance(oci_config, str)
331+
else os.path.expanduser(DEFAULT_LOCATION),
332+
oci_key_profile=profile,
333+
client_kwargs=client_kwargs,
334+
)
335+
signer_generator = AuthFactory().signerGenerator(AuthType.SECURITY_TOKEN)
336+
return signer_generator(signer_args).create_signer()
337+
338+
277339
def create_signer(
278340
auth_type: Optional[str] = AuthType.API_KEY,
279341
oci_config_location: Optional[str] = DEFAULT_LOCATION,
@@ -346,6 +408,12 @@ def create_signer(
346408
>>> signer_callable = oci.auth.signers.InstancePrincipalsSecurityTokenSigner
347409
>>> signer_kwargs = dict(log_requests=True) # will log the request url and response data when retrieving
348410
>>> auth = ads.auth.create_signer(signer_callable=signer_callable, signer_kwargs=signer_kwargs) # instance principals authentication dictionary created based on callable with kwargs parameters
411+
>>> config = dict(
412+
... region=us-ashburn-1,
413+
... key_file=~/.oci/sessions/DEFAULT/oci_api_key.pem,
414+
... security_token_file=~/.oci/sessions/DEFAULT/token
415+
... )
416+
>>> auth = ads.auth.create_signer(auth_type="security_token", config=config) # security token authentication created based on provided config
349417
"""
350418
if signer or signer_callable:
351419
configuration = ads.telemetry.update_oci_client_config()
@@ -365,8 +433,6 @@ def create_signer(
365433
oci_config=config,
366434
client_kwargs=client_kwargs,
367435
)
368-
if config:
369-
auth_type = AuthType.API_KEY
370436

371437
signer_generator = AuthFactory().signerGenerator(auth_type)
372438

@@ -678,6 +744,162 @@ def create_signer(self) -> Dict:
678744
return signer_dict
679745

680746

747+
class SecurityToken(AuthSignerGenerator):
748+
"""
749+
Creates security token auth instance. This signer is intended to be used when signing requests for
750+
a given user - it requires that user's private key and security token.
751+
It prepares extra arguments necessary for creating clients for variety of OCI services.
752+
"""
753+
SECURITY_TOKEN_GENERIC_HEADERS = [
754+
"date",
755+
"(request-target)",
756+
"host"
757+
]
758+
SECURITY_TOKEN_BODY_HEADERS = [
759+
"content-length",
760+
"content-type",
761+
"x-content-sha256"
762+
]
763+
SECURITY_TOKEN_REQUIRED = [
764+
"security_token_file",
765+
"key_file",
766+
"region"
767+
]
768+
769+
def __init__(self, args: Optional[Dict] = None):
770+
"""
771+
Signer created based on args provided. If not provided current values of according arguments
772+
will be used from current global state from AuthState class.
773+
774+
Parameters
775+
----------
776+
args: dict
777+
args that are required to create Security Token signer. Contains keys: oci_config,
778+
oci_config_location, oci_key_profile, client_kwargs.
779+
780+
- oci_config is a configuration dict that can be used to create clients
781+
- oci_config_location - path to config file
782+
- oci_key_profile - the profile to load from config file
783+
- client_kwargs - optional parameters for OCI client creation in next steps
784+
"""
785+
self.oci_config = args.get("oci_config")
786+
self.oci_config_location = args.get("oci_config_location")
787+
self.oci_key_profile = args.get("oci_key_profile")
788+
self.client_kwargs = args.get("client_kwargs")
789+
790+
def create_signer(self) -> Dict:
791+
"""
792+
Creates security token configuration and signer with extra arguments necessary for creating clients.
793+
Signer constructed from the `oci_config` provided. If not 'oci_config', configuration will be
794+
constructed from 'oci_config_location' and 'oci_key_profile' in place.
795+
796+
Returns
797+
-------
798+
dict
799+
Contains keys - config, signer and client_kwargs.
800+
801+
- config contains the configuration information
802+
- signer contains the signer object created. It is instantiated from signer_callable, or
803+
signer provided in args used, or instantiated in place
804+
- client_kwargs contains the `client_kwargs` that was passed in as input parameter
805+
806+
Examples
807+
--------
808+
>>> signer_args = dict(
809+
... client_kwargs=client_kwargs
810+
... )
811+
>>> signer_generator = AuthFactory().signerGenerator(AuthType.SECURITY_TOKEN)
812+
>>> signer_generator(signer_args).create_signer()
813+
"""
814+
if self.oci_config:
815+
configuration = ads.telemetry.update_oci_client_config(self.oci_config)
816+
else:
817+
configuration = ads.telemetry.update_oci_client_config(
818+
oci.config.from_file(self.oci_config_location, self.oci_key_profile)
819+
)
820+
821+
logger.info(f"Using 'security_token' authentication.")
822+
823+
for parameter in self.SECURITY_TOKEN_REQUIRED:
824+
if parameter not in configuration:
825+
raise ValueError(
826+
f"Parameter `{parameter}` must be provided for using `security_token` authentication."
827+
)
828+
829+
self._validate_and_refresh_token(configuration)
830+
831+
return {
832+
"config": configuration,
833+
"signer": oci.auth.signers.SecurityTokenSigner(
834+
token=self._read_security_token_file(configuration.get("security_token_file")),
835+
private_key=oci.signer.load_private_key_from_file(
836+
configuration.get("key_file"), configuration.get("pass_phrase")
837+
),
838+
generic_headers=configuration.get("generic_headers", self.SECURITY_TOKEN_GENERIC_HEADERS),
839+
body_headers=configuration.get("body_headers", self.SECURITY_TOKEN_BODY_HEADERS)
840+
),
841+
"client_kwargs": self.client_kwargs,
842+
}
843+
844+
def _validate_and_refresh_token(self, configuration: Dict[str, Any]):
845+
"""Validates and refreshes security token.
846+
847+
Parameters
848+
----------
849+
configuration: Dict
850+
Security token configuration.
851+
"""
852+
security_token = self._read_security_token_file(configuration.get("security_token_file"))
853+
security_token_container = oci.auth.security_token_container.SecurityTokenContainer(
854+
session_key_supplier=None,
855+
security_token=security_token
856+
)
857+
858+
if not security_token_container.valid():
859+
raise SecurityTokenError(
860+
"Security token has expired. Call `oci session authenticate` to generate new session."
861+
)
862+
863+
time_now = int(time.time())
864+
time_expired = security_token_container.get_jwt()["exp"]
865+
if time_expired - time_now < SECURITY_TOKEN_LEFT_TIME:
866+
if not self.oci_config_location:
867+
logger.warning("Can not auto-refresh token. Specify parameter `oci_config_location` through ads.set_auth() or ads.auth.create_signer().")
868+
else:
869+
result = os.system(f"oci session refresh --config-file {self.oci_config_location} --profile {self.oci_key_profile}")
870+
if result == 1:
871+
logger.warning(
872+
"Some error happened during auto-refreshing the token. Continue using the current one that's expiring in less than {SECURITY_TOKEN_LEFT_TIME} seconds."
873+
"Please follow steps in https://docs.oracle.com/en-us/iaas/Content/API/SDKDocs/clitoken.htm to renew token."
874+
)
875+
876+
date_time = datetime.fromtimestamp(time_expired).strftime("%Y-%m-%d %H:%M:%S")
877+
logger.info(f"Session is valid until {date_time}.")
878+
879+
def _read_security_token_file(self, security_token_file: str) -> str:
880+
"""Reads security token from file.
881+
882+
Parameters
883+
----------
884+
security_token_file: str
885+
The path to security token file.
886+
887+
Returns
888+
-------
889+
str:
890+
Security token string.
891+
"""
892+
if not os.path.isfile(security_token_file):
893+
raise ValueError("Invalid `security_token_file`. Specify a valid path.")
894+
try:
895+
token = None
896+
with open(security_token_file, 'r') as f:
897+
token = f.read()
898+
return token
899+
except:
900+
raise
901+
902+
681903
class AuthFactory:
682904
"""
683905
AuthFactory class which contains list of registered signers and alllows to register new signers.
@@ -687,12 +909,14 @@ class AuthFactory:
687909
* APIKey
688910
* ResourcePrincipal
689911
* InstancePrincipal
912+
* SecurityToken
690913
"""
691914

692915
classes = {
693916
AuthType.API_KEY: APIKey,
694917
AuthType.RESOURCE_PRINCIPAL: ResourcePrincipal,
695918
AuthType.INSTANCE_PRINCIPAL: InstancePrincipal,
919+
AuthType.SECURITY_TOKEN: SecurityToken,
696920
}
697921

698922
@classmethod
@@ -726,7 +950,7 @@ def signerGenerator(self, iam_type: Optional[str] = "api_key"):
726950
727951
Returns
728952
-------
729-
:class:`APIKey` or :class:`ResourcePrincipal` or :class:`InstancePrincipal`
953+
:class:`APIKey` or :class:`ResourcePrincipal` or :class:`InstancePrincipal` or :class:`SecurityToken`
730954
returns one of classes, which implements creation of signer of specified type
731955
732956
Raises

ads/opctl/cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,7 @@ def init_vscode(**kwargs):
230230
"--auth",
231231
"-a",
232232
help="authentication method",
233-
type=click.Choice(["api_key", "resource_principal"]),
233+
type=click.Choice(["api_key", "resource_principal", "security_token"]),
234234
default=None,
235235
),
236236
]

ads/opctl/config/merger.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ def _fill_config_with_defaults(self, ads_config_path: str) -> None:
115115
else:
116116
self.config["execution"]["auth"] = AuthType.API_KEY
117117
# determine profile
118-
if self.config["execution"]["auth"] != AuthType.API_KEY:
118+
if self.config["execution"]["auth"] == AuthType.RESOURCE_PRINCIPAL:
119119
profile = self.config["execution"]["auth"].upper()
120120
exec_config.pop("oci_profile", None)
121121
self.config["execution"]["oci_profile"] = None

docs/source/user_guide/cli/authentication.rst

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -62,13 +62,28 @@ You can choose to use the instance principal to authenticate while using the Acc
6262
mc = ModelCatalog(compartment_id="<compartment_id>")
6363
mc.list_models()
6464
65+
4. Authenticating Using Security Token
66+
--------------------------------------
6567

66-
4. Overriding Defaults
68+
**Prerequisite**
69+
70+
* You have setup security token as per the instruction `here <https://docs.oracle.com/en-us/iaas/Content/API/SDKDocs/clitoken.htm>`_
71+
72+
You can choose to use the security token to authenticate while using the Accelerated Data Science (ADS) SDK by running ``ads.set_auth(auth='security_token')``. For example:
73+
74+
.. code-block:: python
75+
76+
import ads
77+
ads.set_auth(auth='security_token')
78+
mc = ModelCatalog(compartment_id="<compartment_id>")
79+
mc.list_models()
80+
81+
5. Overriding Defaults
6782
----------------------
6883

6984
The default authentication that is used by ADS is set with the ``set_auth()`` method. However, each relevant ADS method has an optional parameter to specify the authentication method to use. The most common use case for this is when you have different permissions in different API keys or there are differences between the permissions granted in the resource principals and your API keys.
7085

71-
By default, ADS uses API keys to sign requests to OCI resources. The ``set_auth()`` method is used to explicitly set a default signing method. This method accepts one of three strings ``"api_key"``, ``"resource_principal"``, or ``instance_principal``.
86+
By default, ADS uses API keys to sign requests to OCI resources. The ``set_auth()`` method is used to explicitly set a default signing method. This method accepts one of four strings ``"api_key"``, ``"resource_principal"``, ``instance_principal`` or ``security_token``.
7287

7388
The ``~/.oci/config`` configuration allow for multiple configurations to be stored in the same file. The ``set_auth()`` method takes is ``oci_config_location`` parameter that specifies the location of the configuration, and the default is ``"~/.oci/config"``. Each configuration is called a profile, and the default profile is ``DEFAULT``. The ``set_auth()`` method takes in a parameter ``profile``. It specifies which profile in the ``~/.oci/config`` configuration file to use. In this context, the ``profile`` parameter is only used when API keys are being used. If no value for ``profile`` is specified, then the ``DEFAULT`` profile section is used.
7489

@@ -97,6 +112,7 @@ The ``~/.oci/config`` configuration allow for multiple configurations to be stor
97112
98113
ads.set_auth("resource_principal") # default signer is set to resource principal authentication
99114
ads.set_auth("instance_principal") # default signer is set to instance principal authentication
115+
ads.set_auth("security_token") # default signer is set to security token authentication
100116
101117
singer = oci.auth.signers.ResourcePrincipalsFederationSigner()
102118
ads.set_auth(config={}, singer=signer) # default signer is set to ResourcePrincipalsFederationSigner
@@ -122,9 +138,12 @@ Additional signers may be provided by running ``set_auth()`` with ``signer`` or
122138
oc.OCIClientFactory(**auth).object_storage
123139
124140
# Example 3: Create Object Storage client with timeout set to 6000 using API Key authentication.
125-
auth = authutil.api_keys(oci_config="/home/datascience/.oci/config", profile="TEST", kwargs={"timeout": 6000})
141+
auth = authutil.api_keys(oci_config="/home/datascience/.oci/config", profile="TEST", client_kwargs={"timeout": 6000})
126142
oc.OCIClientFactory(**auth).object_storage
127143
144+
# Example 4: Create Object Storage client with timeout set to 6000 using security token authentication.
145+
auth = authutil.security_token(oci_config="/home/datascience/.oci/config", profile="test_session", client_kwargs={"timeout": 6000})
146+
oc.OCIClientFactory(**auth).object_storage
128147
129148
In the this example, the default authentication uses API keys specified with the ``set_auth`` method. However, since the ``os_auth`` is specified to use resource principals, the notebook session uses the resource principal to access OCI Object Store.
130149

@@ -144,11 +163,14 @@ More signers can be created using the ``create_signer()`` method. With the ``aut
144163
# Example 1. Create signer that uses instance principals
145164
auth = ads.auth.create_signer("instance_principal")
146165
147-
# Example 2. Provide a ResourcePrincipalsFederationSigner object
166+
# Example 2. Create signer that uses security token
167+
auth = ads.auth.create_signer("security_token", profile="test_session")
168+
169+
# Example 3. Provide a ResourcePrincipalsFederationSigner object
148170
singer = oci.auth.signers.ResourcePrincipalsFederationSigner()
149171
auth = ads.auth.create_signer(config={}, singer=signer)
150172
151-
# Example 3. Create signer that uses instance principals with log requests enabled
173+
# Example 4. Create signer that uses instance principals with log requests enabled
152174
signer_callable = oci.auth.signers.InstancePrincipalsSecurityTokenSigner
153175
signer_kwargs = dict(log_requests=True) # will log the request url and response data when retrieving
154176
auth = ads.auth.create_signer(signer_callable=signer_callable, signer_kwargs=signer_kwargs)

0 commit comments

Comments
 (0)