Skip to content

Commit f4a958d

Browse files
committed
feat: support CloudSSO credentials provider
1 parent fc52a43 commit f4a958d

File tree

7 files changed

+672
-17
lines changed

7 files changed

+672
-17
lines changed

alibabacloud_credentials/provider/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from .cli_profile import CLIProfileCredentialsProvider
1010
from .profile import ProfileCredentialsProvider
1111
from .default import DefaultCredentialsProvider
12+
from .cloud_sso import CloudSSOCredentialsProvider
1213

1314
__all__ = [
1415
'StaticAKCredentialsProvider',
@@ -21,5 +22,6 @@
2122
'URLCredentialsProvider',
2223
'CLIProfileCredentialsProvider',
2324
'ProfileCredentialsProvider',
24-
'DefaultCredentialsProvider'
25+
'DefaultCredentialsProvider',
26+
'CloudSSOCredentialsProvider'
2527
]

alibabacloud_credentials/provider/cli_profile.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,12 @@
44

55
import aiofiles
66

7-
from alibabacloud_credentials.provider import StaticAKCredentialsProvider, EcsRamRoleCredentialsProvider, \
8-
RamRoleArnCredentialsProvider, OIDCRoleArnCredentialsProvider, StaticSTSCredentialsProvider
7+
from .static_ak import StaticAKCredentialsProvider
8+
from .ecs_ram_role import EcsRamRoleCredentialsProvider
9+
from .ram_role_arn import RamRoleArnCredentialsProvider
10+
from .oidc import OIDCRoleArnCredentialsProvider
11+
from .static_sts import StaticSTSCredentialsProvider
12+
from .cloud_sso import CloudSSOCredentialsProvider
913
from .refreshable import Credentials
1014
from alibabacloud_credentials_api import ICredentialsProvider
1115
from alibabacloud_credentials.utils import auth_constant as ac
@@ -163,6 +167,14 @@ def _get_credentials_provider(self, config: Dict, profile_name: str) -> ICredent
163167
sts_region_id=profile.get('sts_region'),
164168
enable_vpc=profile.get('enable_vpc'),
165169
)
170+
elif mode == "CloudSSO":
171+
return CloudSSOCredentialsProvider(
172+
sign_in_url=profile.get('cloud_sso_sign_in_url'),
173+
account_id=profile.get('cloud_sso_account_id'),
174+
access_config=profile.get('cloud_sso_access_config'),
175+
access_token=profile.get('access_token'),
176+
access_token_expire=profile.get('cloud_sso_access_token_expire'),
177+
)
166178
else:
167179
raise CredentialException(f"unsupported profile mode '{mode}' form cli credentials file.")
168180

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import calendar
2+
import json
3+
import time
4+
from urllib.parse import urlparse
5+
6+
from alibabacloud_credentials.provider.refreshable import Credentials, RefreshResult, RefreshCachedSupplier
7+
from alibabacloud_credentials.http import HttpOptions
8+
from Tea.core import TeaCore
9+
from alibabacloud_credentials_api import ICredentialsProvider
10+
from alibabacloud_credentials.utils import parameter_helper as ph
11+
from alibabacloud_credentials.exceptions import CredentialException
12+
13+
14+
def _get_stale_time(expiration: int) -> int:
15+
if expiration < 0:
16+
return int(time.mktime(time.localtime())) + 60 * 60
17+
return expiration - 15 * 60
18+
19+
20+
class CloudSSOCredentialsProvider(ICredentialsProvider):
21+
DEFAULT_CONNECT_TIMEOUT = 5000
22+
DEFAULT_READ_TIMEOUT = 10000
23+
24+
def __init__(self, *,
25+
sign_in_url: str = None,
26+
account_id: str = None,
27+
access_config: str = None,
28+
access_token: str = None,
29+
access_token_expire: int = 0,
30+
http_options: HttpOptions = None):
31+
32+
self._sign_in_url = sign_in_url
33+
self._account_id = account_id
34+
self._access_config = access_config
35+
self._access_token = access_token
36+
self._access_token_expire = access_token_expire
37+
38+
if self._access_token is None or self._access_token_expire == 0 or self._access_token_expire - int(
39+
time.mktime(time.localtime())) <= 0:
40+
raise ValueError(
41+
'CloudSSO access token is empty or expired, please re-login with cli')
42+
if self._sign_in_url is None or self._account_id is None or self._access_config is None:
43+
raise ValueError(
44+
'CloudSSO sign in url or account id or access config is empty')
45+
46+
self._http_options = http_options if http_options is not None else HttpOptions()
47+
self._runtime_options = {
48+
'connectTimeout': self._http_options.connect_timeout if self._http_options.connect_timeout is not None else CloudSSOCredentialsProvider.DEFAULT_CONNECT_TIMEOUT,
49+
'readTimeout': self._http_options.read_timeout if self._http_options.read_timeout is not None else CloudSSOCredentialsProvider.DEFAULT_READ_TIMEOUT,
50+
'httpsProxy': self._http_options.proxy
51+
}
52+
self._credentials_cache = RefreshCachedSupplier(
53+
refresh_callable=self._refresh_credentials,
54+
refresh_callable_async=self._refresh_credentials_async,
55+
)
56+
57+
def get_credentials(self) -> Credentials:
58+
return self._credentials_cache._sync_call()
59+
60+
async def get_credentials_async(self) -> Credentials:
61+
return await self._credentials_cache._async_call()
62+
63+
def _refresh_credentials(self) -> RefreshResult[Credentials]:
64+
r = urlparse(self._sign_in_url)
65+
tea_request = ph.get_new_request()
66+
tea_request.headers['host'] = r.hostname
67+
tea_request.port = r.port
68+
tea_request.protocol = r.scheme
69+
tea_request.method = 'POST'
70+
tea_request.pathname = '/cloud-credentials'
71+
72+
tea_request.body = json.dumps({
73+
'AccountId': self._account_id,
74+
'AccessConfigurationId': self._access_config,
75+
})
76+
77+
tea_request.headers['Accept'] = 'application/json'
78+
tea_request.headers['Content-Type'] = 'application/json'
79+
tea_request.headers['Authorization'] = f'Bearer {self._access_token}'
80+
81+
response = TeaCore.do_action(tea_request, self._runtime_options)
82+
83+
if response.status_code != 200:
84+
raise CredentialException(
85+
f'error refreshing credentials from sso, http_code: {response.status_code}, result: {response.body.decode("utf-8")}')
86+
87+
dic = json.loads(response.body.decode('utf-8'))
88+
if 'CloudCredential' not in dic:
89+
raise CredentialException(
90+
f'error retrieving credentials from sso result: {response.body.decode("utf-8")}')
91+
92+
cre = dic.get('CloudCredential')
93+
if 'AccessKeyId' not in cre or 'AccessKeySecret' not in cre or 'SecurityToken' not in cre:
94+
raise CredentialException(
95+
f'error retrieving credentials from sso result: {response.body.decode("utf-8")}')
96+
97+
# 先转换为时间数组
98+
time_array = time.strptime(cre.get('Expiration'), '%Y-%m-%dT%H:%M:%SZ')
99+
# 转换为时间戳
100+
expiration = calendar.timegm(time_array)
101+
credentials = Credentials(
102+
access_key_id=cre.get('AccessKeyId'),
103+
access_key_secret=cre.get('AccessKeySecret'),
104+
security_token=cre.get('SecurityToken'),
105+
expiration=expiration,
106+
provider_name=self.get_provider_name()
107+
)
108+
return RefreshResult(value=credentials,
109+
stale_time=_get_stale_time(expiration))
110+
111+
async def _refresh_credentials_async(self) -> RefreshResult[Credentials]:
112+
r = urlparse(self._sign_in_url)
113+
tea_request = ph.get_new_request()
114+
tea_request.headers['host'] = r.hostname
115+
tea_request.port = r.port
116+
tea_request.protocol = r.scheme
117+
tea_request.method = 'POST'
118+
tea_request.pathname = '/cloud-credentials'
119+
120+
tea_request.body = json.dumps({
121+
'AccountId': self._account_id,
122+
'AccessConfigurationId': self._access_config,
123+
})
124+
125+
tea_request.headers['Accept'] = 'application/json'
126+
tea_request.headers['Content-Type'] = 'application/json'
127+
tea_request.headers['Authorization'] = f'Bearer {self._access_token}'
128+
129+
response = await TeaCore.async_do_action(tea_request, self._runtime_options)
130+
131+
if response.status_code != 200:
132+
raise CredentialException(
133+
f'error refreshing credentials from sso, http_code: {response.status_code}, result: {response.body.decode("utf-8")}')
134+
135+
dic = json.loads(response.body.decode('utf-8'))
136+
if 'CloudCredential' not in dic:
137+
raise CredentialException(
138+
f'error retrieving credentials from sso result: {response.body.decode("utf-8")}')
139+
140+
cre = dic.get('CloudCredential')
141+
if 'AccessKeyId' not in cre or 'AccessKeySecret' not in cre or 'SecurityToken' not in cre:
142+
raise CredentialException(
143+
f'error retrieving credentials from sso result: {response.body.decode("utf-8")}')
144+
145+
# 先转换为时间数组
146+
time_array = time.strptime(cre.get('Expiration'), '%Y-%m-%dT%H:%M:%SZ')
147+
# 转换为时间戳
148+
expiration = calendar.timegm(time_array)
149+
credentials = Credentials(
150+
access_key_id=cre.get('AccessKeyId'),
151+
access_key_secret=cre.get('AccessKeySecret'),
152+
security_token=cre.get('SecurityToken'),
153+
expiration=expiration,
154+
provider_name=self.get_provider_name()
155+
)
156+
return RefreshResult(value=credentials,
157+
stale_time=_get_stale_time(expiration))
158+
159+
def get_provider_name(self) -> str:
160+
return 'cloud_sso'

alibabacloud_credentials/provider/ecs_ram_role.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from alibabacloud_credentials.exceptions import CredentialException
1616

1717
log = logging.getLogger('credentials')
18-
log.setLevel(logging.DEBUG)
18+
log.setLevel(logging.INFO)
1919
ch = logging.StreamHandler()
2020
log.addHandler(ch)
2121

alibabacloud_credentials/provider/refreshable.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from alibabacloud_credentials_api import ICredentials
1515

1616
log = logging.getLogger('credentials')
17-
log.setLevel(logging.DEBUG)
17+
log.setLevel(logging.INFO)
1818
ch = logging.StreamHandler()
1919
log.addHandler(ch)
2020

tests/provider/test_cli_profile.py

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import asyncio
44
import os
55
import json
6+
import time
67
from alibabacloud_credentials.provider.cli_profile import (
78
CLIProfileCredentialsProvider,
89
CredentialException,
@@ -13,7 +14,8 @@
1314
StaticAKCredentialsProvider,
1415
RamRoleArnCredentialsProvider,
1516
EcsRamRoleCredentialsProvider,
16-
OIDCRoleArnCredentialsProvider
17+
OIDCRoleArnCredentialsProvider,
18+
CloudSSOCredentialsProvider
1719
)
1820
from alibabacloud_credentials.utils import auth_constant as ac
1921

@@ -80,6 +82,15 @@ def setUp(self):
8082
"external_id": "test_external_id",
8183
"sts_region": "test_sts_region",
8284
"enable_vpc": True
85+
},
86+
{
87+
"name": "cloud_sso_profile",
88+
"mode": "CloudSSO",
89+
"cloud_sso_sign_in_url": "https://sso.example.com",
90+
"cloud_sso_account_id": "test_account_id",
91+
"cloud_sso_access_config": "test_access_config",
92+
"access_token": "test_access_token",
93+
"cloud_sso_access_token_expire": int(time.mktime(time.localtime())) + 1000
8394
}
8495
]
8596
}
@@ -243,9 +254,30 @@ def test_get_credentials_valid_chainable_ram_role_arn(self):
243254
self.assertEqual(credentials_provider._external_id, 'test_external_id')
244255
self.assertEqual(credentials_provider._sts_endpoint, 'sts-vpc.test_sts_region.aliyuncs.com')
245256

257+
def test_get_credentials_valid_cloud_sso(self):
258+
"""
259+
Test case 7: Valid input, successfully retrieves credentials for CloudSSO mode
260+
"""
261+
with patch('alibabacloud_credentials.provider.cli_profile.au.environment_cli_profile_disabled', False):
262+
with patch('os.path.exists', return_value=True):
263+
with patch('os.path.isfile', return_value=True):
264+
with patch('alibabacloud_credentials.provider.cli_profile._load_config', return_value=self.config):
265+
provider = CLIProfileCredentialsProvider(profile_name="cloud_sso_profile")
266+
267+
credentials_provider = provider._get_credentials_provider(config=self.config,
268+
profile_name="cloud_sso_profile")
269+
270+
self.assertIsInstance(credentials_provider, CloudSSOCredentialsProvider)
271+
272+
self.assertEqual(credentials_provider._sign_in_url, 'https://sso.example.com')
273+
self.assertEqual(credentials_provider._account_id, 'test_account_id')
274+
self.assertEqual(credentials_provider._access_config, 'test_access_config')
275+
self.assertEqual(credentials_provider._access_token, 'test_access_token')
276+
self.assertTrue(credentials_provider._access_token_expire > int(time.mktime(time.localtime())))
277+
246278
def test_get_credentials_cli_profile_disabled(self):
247279
"""
248-
Test case 7: CLI profile disabled raises CredentialException
280+
Test case 8: CLI profile disabled raises CredentialException
249281
"""
250282
with patch('alibabacloud_credentials.provider.cli_profile.au.environment_cli_profile_disabled', 'True'):
251283
provider = CLIProfileCredentialsProvider(profile_name=self.profile_name)
@@ -257,7 +289,7 @@ def test_get_credentials_cli_profile_disabled(self):
257289

258290
def test_get_credentials_profile_name_not_exists(self):
259291
"""
260-
Test case 8: Profile file does not exist raises CredentialException
292+
Test case 9: Profile file does not exist raises CredentialException
261293
"""
262294
with patch('alibabacloud_credentials.provider.cli_profile.au.environment_cli_profile_disabled', 'False'):
263295
provider = CLIProfileCredentialsProvider(profile_name='not_exists')
@@ -269,7 +301,7 @@ def test_get_credentials_profile_name_not_exists(self):
269301

270302
def test_get_credentials_profile_file_not_exists(self):
271303
"""
272-
Test case 8: Profile file does not exist raises CredentialException
304+
Test case 10: Profile file does not exist raises CredentialException
273305
"""
274306
with patch('alibabacloud_credentials.provider.cli_profile.au.environment_cli_profile_disabled', 'False'):
275307
with patch('os.path.exists', return_value=False):
@@ -282,7 +314,7 @@ def test_get_credentials_profile_file_not_exists(self):
282314

283315
def test_get_credentials_profile_file_not_file(self):
284316
"""
285-
Test case 9: Profile file is not a file raises CredentialException
317+
Test case 11: Profile file is not a file raises CredentialException
286318
"""
287319
with patch('alibabacloud_credentials.provider.cli_profile.au.environment_cli_profile_disabled', 'False'):
288320
with patch('os.path.exists', return_value=True):
@@ -296,7 +328,7 @@ def test_get_credentials_profile_file_not_file(self):
296328

297329
def test_get_credentials_invalid_json_format(self):
298330
"""
299-
Test case 10: Invalid JSON format in profile file raises CredentialException
331+
Test case 12: Invalid JSON format in profile file raises CredentialException
300332
"""
301333
with patch('alibabacloud_credentials.provider.cli_profile.au.environment_cli_profile_disabled', 'False'):
302334
with patch('os.path.exists', return_value=True):
@@ -313,7 +345,7 @@ def test_get_credentials_invalid_json_format(self):
313345

314346
def test_get_credentials_empty_json(self):
315347
"""
316-
Test case 11: Empty JSON in profile file raises CredentialException
348+
Test case 13: Empty JSON in profile file raises CredentialException
317349
"""
318350
with patch('alibabacloud_credentials.provider.cli_profile.au.environment_cli_profile_disabled', 'False'):
319351
with patch('os.path.exists', return_value=True):
@@ -329,7 +361,7 @@ def test_get_credentials_empty_json(self):
329361

330362
def test_get_credentials_missing_profiles(self):
331363
"""
332-
Test case 12: Missing profiles in JSON raises CredentialException
364+
Test case 14: Missing profiles in JSON raises CredentialException
333365
"""
334366
with patch('alibabacloud_credentials.provider.cli_profile.au.environment_cli_profile_disabled', 'False'):
335367
with patch('os.path.exists', return_value=True):
@@ -346,7 +378,7 @@ def test_get_credentials_missing_profiles(self):
346378

347379
def test_get_credentials_invalid_profile_mode(self):
348380
"""
349-
Test case 13: Invalid profile mode raises CredentialException
381+
Test case 15: Invalid profile mode raises CredentialException
350382
"""
351383
invalid_config = {
352384
"current": "invalid_profile",
@@ -374,7 +406,7 @@ def test_get_credentials_invalid_profile_mode(self):
374406

375407
def test_get_credentials_async_valid_ak(self):
376408
"""
377-
Test case 14: Valid input, successfully retrieves credentials for AK mode
409+
Test case 16: Valid input, successfully retrieves credentials for AK mode
378410
"""
379411
with patch('alibabacloud_credentials.provider.cli_profile.au.environment_cli_profile_disabled', 'False'):
380412
with patch('os.path.exists', return_value=True):
@@ -398,7 +430,7 @@ def test_get_credentials_async_valid_ak(self):
398430
@patch('builtins.open', new_callable=MagicMock)
399431
def test_load_config_file_not_found(self, mock_open):
400432
"""
401-
Test case 15: File not found raises FileNotFoundError
433+
Test case 17: File not found raises FileNotFoundError
402434
"""
403435
mock_open.side_effect = FileNotFoundError(f"No such file or directory: '{self.profile_file}'")
404436

@@ -410,7 +442,7 @@ def test_load_config_file_not_found(self, mock_open):
410442
@patch('builtins.open', new_callable=MagicMock)
411443
def test_load_config_invalid_json(self, mock_open):
412444
"""
413-
Test case 16: Invalid JSON format raises json.JSONDecodeError
445+
Test case 18: Invalid JSON format raises json.JSONDecodeError
414446
"""
415447
invalid_json = "invalid json content"
416448
mock_open.return_value.__enter__.return_value.read.return_value = invalid_json

0 commit comments

Comments
 (0)