Skip to content

Commit 4edbc5a

Browse files
committed
Added code that allows authorization with service_account_key.
1 parent 5049ab4 commit 4edbc5a

File tree

6 files changed

+153
-28
lines changed

6 files changed

+153
-28
lines changed

setup.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,17 @@
33
packages = find_packages('.', include=['yandexcloud*', 'yandex*'])
44

55
setup(name='yandexcloud',
6-
version='0.5',
6+
version='0.6',
77
description='The Yandex.Cloud official SDK',
88
url='https://github.com/yandex-cloud/python-sdk',
99
author='Yandex LLC',
1010
author_email='FIXME',
1111
license='MIT',
1212
install_requires=[
13+
'cryptography',
1314
'grpcio',
1415
'googleapis-common-protos',
16+
'pyjwt',
1517
'six',
1618
],
1719
tests_require=['pytest'],

tests/conftest.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import pytest
2+
3+
4+
@pytest.fixture()
5+
def token():
6+
return "AAAA00000000000000000000000000000000000"
7+
8+
9+
@pytest.fixture()
10+
def service_account_key():
11+
return {
12+
"id": "ajeboa0du6edu6m43c3t",
13+
"service_account_id": "ajeq7dsmihqple6761c5",
14+
"created_at": "2018-10-31T09:30:52Z",
15+
"key_algorithm": "RSA_4096",
16+
"public_key": "-----BEGIN PUBLIC KEY-----\nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA0q4HXY6/7jzA3iwofyTq\nxJ+VPR2fQGrhd+nEi32lemw8XEqVpr4wvzaJHdW2921Z7nsEKJPJq9ZCaDpnkdpS\npGPnJCUtJSYQvjs5yrYbEB00LGGi4pERDNWbsX+MyyMl+0Mqd3G3wiu/T8k31T5F\ngmOK1KPnlDQ6JjZ+OQWkBojBTGGkaCsYKuDwuKfsHZQCqhTt8pLIN7ZiiXWRIB4Q\n4GfBuBWUfhgncbNCj+PBEBvy1auFnI0CHQ8T9cHqnh9UQIi0qsxVslICv4Z5iX4y\nYCrRfSw3UJOqQ+mkttSNBjnJC7TpC4uQyc98XC+kLzP1i8/nNv967K9eWA6MVsHF\nZqAkFJYcUn6Bx/f3FDiIcW0tR5P/FgDtVTvQAAdUW+l02P27JOqRyD9oDX/y889/\nc1TGbXlhmaWCqjIVoUnUBlnlHAB7v8X4aqlCu9vwP0DUaXdI/Yxf7VcG6B7wFFZN\noM2k6X1J3LSMdrFTXSLbduv/n0mMLUurUx1D0YIIrk2Kv1N63YNiVtPWdFkGHQs6\nshVgrpiTBUm0VBME7EYKwKQUK7pZ0gn6/IeZpgel0aPCQtaF9FIffLi8KJaMVbJi\nNGgvr4HTejzn/jabWuLc3rN62AexNYUqnRMfmfNPXyArJ0A54tl2u/TKoPmw0w5t\nYAwgJ+mSGlylBJbZy2CBp2sCAwEAAQ==\n-----END PUBLIC KEY-----\n",
17+
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQDSrgddjr/uPMDe\nLCh/JOrEn5U9HZ9AauF36cSLfaV6bDxcSpWmvjC/Nokd1bb3bVnuewQok8mr1kJo\nOmeR2lKkY+ckJS0lJhC+OznKthsQHTQsYaLikREM1Zuxf4zLIyX7Qyp3cbfCK79P\nyTfVPkWCY4rUo+eUNDomNn45BaQGiMFMYaRoKxgq4PC4p+wdlAKqFO3yksg3tmKJ\ndZEgHhDgZ8G4FZR+GCdxs0KP48EQG/LVq4WcjQIdDxP1weqeH1RAiLSqzFWyUgK/\nhnmJfjJgKtF9LDdQk6pD6aS21I0GOckLtOkLi5DJz3xcL6QvM/WLz+c2/3rsr15Y\nDoxWwcVmoCQUlhxSfoHH9/cUOIhxbS1Hk/8WAO1VO9AAB1Rb6XTY/bsk6pHIP2gN\nf/Lzz39zVMZteWGZpYKqMhWhSdQGWeUcAHu/xfhqqUK72/A/QNRpd0j9jF/tVwbo\nHvAUVk2gzaTpfUnctIx2sVNdItt26/+fSYwtS6tTHUPRggiuTYq/U3rdg2JW09Z0\nWQYdCzqyFWCumJMFSbRUEwTsRgrApBQrulnSCfr8h5mmB6XRo8JC1oX0Uh98uLwo\nloxVsmI0aC+vgdN6POf+Npta4tzes3rYB7E1hSqdEx+Z809fICsnQDni2Xa79Mqg\n+bDTDm1gDCAn6ZIaXKUEltnLYIGnawIDAQABAoICACXvsmHVZ5gllnErMGucoS2g\nssXbhKab2Fe4X2zixh5iSQgxYfsxeiOkVVJq/lRVe4Em45vO6NypazHLeoTX9FOn\nraJjk1qCHTe0AHcRDZR8Pb3UIvl7N7/A4xU2K4sUnC0/bfEuJ/Gt4Pgj+orKeMe+\n1uvtS7DzKplg7J+l9WA71drEJk+fmu11rcMCcdDtqwEnXaV1atolXF72LZjD8TQH\nWumj8SY3gTrHFbBFSal17ucsyJVlCsFiyqxRK8cnSwuH0kiDHNdMTzRfqZjpgXax\nnyFUCe3XeSxbcQ5+/ZnmY95YyDINAphkZTdQWNcrGwb++9p6bI8cEPf4PqsMn1fE\ngCvhjTpfVLhTbjvA6QH1Za6j4BQxR3+zUw1yzCsuma00r9m1H0/4aQwBhRN6bbKs\nf2OwPmwSMtqzrklqODmLSQbXyPIkb6xoky6zRNJdq3uwBORILgOvzNiM4jUrBjue\nfUmlblu7n2Qw2POJRjrNGEUasYUhZCk9UAlGl8ThTqMo5r2pfT0p0MuQrCzsg4ch\nylM7vzqKhrGdHlIEBr//AJ2C/NHqZjiX2h3H9gfoFiNCx0meOtjDmen9z6vCtiOs\n7JcAHO30z5KuRd59nPO3GuWeXe6SlWWymgv9DiNAQXx163lyBpYvXI8F8KzgKvM+\nbtQzUXpr/Q2/KOcSgl9hAoIBAQD9Vo4PZQ9FOzJSxXPOHeMbpP3lizHoPwmDbRjT\nCu5d8+a9IuHmsINm5bzShPJQauZMsaJvyfHd6Q1RlieJ6l/qObSHrN9LNwVYZxJ8\nTZiyufk4FrgPX0K6rzNUHxCG/2R5A/qfg32BgQ8+h+rK0ZSPVGnVUMRpofTPubBE\nP9lzy/3pcqvJjljI2BtbFYYanB4WWrtDCEZOcLU6Nny7ll/N5Y1ZFFcrL1eiB2kh\nFvFp1ScgAtjTWoxEPIPJK0hVUkMnWnbMMCpJ+WXg+2blDEY2H8BTk47EyN7P/Y3G\nFhujduNLCJbwCuGn8wchCgvSFuy56UcbLpdu8e1R/TEpL5vxAoIBAQDU5LqS7TyS\n0p7tOXQuLgNuPXezuu8yC/eIg9wfUWPEMNaFS6er75AKOqzykFMHnNimPnuxMNLI\nmXwb2bd6J9PfRJ6eqsf60E1iDD+F9KiKSd+btGxh5soc8wVjTWbghW47DfWkAZGX\nw0dWZTh2PY41HupU6lizrBMPnx2xFQz4CgU3DjDlhSVDSbkJ64Wt0RDKICe+OQWe\nlhKmC8fjo9IO5W/QRIcOmqO+1W3nUko84CDVlh+mJ2Hxi2N3+xUT9XJee0id81u2\nVO6tH20+zDTfsu8RFJXBL2Mx2WJ4MTV3ASxvBbUMG5sAOlFb4SL6p6ODzZwOwlDZ\ny/T3tQHr+IUbAoIBAAp9vSBSFRHO48SdvK/6eN86M/F/lC+D/Mbei7qhp0FoylNm\n0GgXQznNpcYqD0bZRnRCnvF2MXf5IL4SM8z4UcSHYzyDIjQhMS16Bz/yjrJIFVQH\nTNQGI+NLQhrntm2Awg5o5cYZUec9Cv6R7l071KUi38cfsyKUvGilzfDlnAG5nug+\nAXM1W+PlXyykdYtAj9ZpJ3wdKZwx+q9QdlXmYk1KhlH8D6gQK9bf67CdHJ4/X4Fp\n3MTT6R8iSmrYSgSOhY1pp6XJENdDZr6sapRtr7KqGfLcF3t6vg9q9qYPYFGiqMMA\ntg92w+WKoO7zVY37uQ3x5SnxAgBsMGHG1HRaLmECggEBAIEuVYQIDkRtJ2B9B2Fq\nLEy9YaAeozv0BPzCPlSGl4oZtGHnuVNcJ0P9vKnnJ2qsIs4lhfrLzGtKrwNbRbkK\n58ZHphRTPsuTkBEZq4YGIirfjp61iTqSxztvv2o1MmK0tGGDI/Wjugujw+rJuswM\np/jVzI1AMhi8JkjJXUPxqQ/tTKLOqp7q/uRonK5HSrNg89YiUttbUGydVa2J4n3g\nDvtY/1MZ8fXLoeaPLYQ6668qtOHFmWjB5u2hjfbk1TJqMj7ggfzOCW2G9dj5A9oi\nIUdIFUaA/ineLku2Q8j42x9eB+9KQESbj59Aw9ODtizwggjdP3+5K0QtPXT9UbA0\n+dcCggEAfpDfGOMurnFZxh7AYU3HfFLR0LIDqc4JX/SA8WlsbFUfM8ujaHhKoR+5\nnWWTouuOy8lJlXVnqUfKvG4Ty0+2QvcTFE50h167AewsHmDqJ4oELJ6kCbMEUzpk\nzILaeiCJlbldkfi4ztA7hT8Dfv+yKmi9GA2pyoMbdsVwG4xPDkA/R0jj7H5kkrh1\nAv/K+T674XEr0ReHxEIxRFFQ0K/lyOxRIdxGssb30SNS3VvKTFtvFDKTm0uP7MYD\ndSc0bk6fmeN0bR/Og2/S1ZEkQNxUFBPx92e9T4g/bi/2rIOdl4xcpwjW1By2UkNG\nawxDbYxnTunk1YxP7KA0/bDnu/OZlQ==\n-----END PRIVATE KEY-----\n"
18+
}

tests/test_service_account_auth.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import pytest
2+
import jwt
3+
import time
4+
5+
from yandexcloud._auth_fabric import get_auth_token_request_func
6+
7+
8+
def test_both_params_error(token, service_account_key):
9+
with pytest.raises(RuntimeError) as e:
10+
get_auth_token_request_func(token=token, service_account_key=service_account_key)
11+
12+
assert str(e.value) == "Conflicting API credentials properties 'token' and 'service-account-key' are set."
13+
14+
15+
@pytest.mark.parametrize("key, error_msg", [
16+
("id", "Invalid Service Account Key: missing key object id."),
17+
("service_account_id", "Invalid Service Account Key: missing service account id."),
18+
("private_key", "Invalid Service Account Key: missing private key."),
19+
])
20+
def test_service_account_no_id(service_account_key, key, error_msg):
21+
service_account_key.pop(key)
22+
23+
with pytest.raises(RuntimeError) as e:
24+
get_auth_token_request_func(service_account_key=service_account_key)
25+
26+
assert str(e.value) == error_msg
27+
28+
29+
def test_oauth_token(token):
30+
request_func = get_auth_token_request_func(token=token)
31+
request = request_func()
32+
assert token == request.yandex_passport_oauth_token
33+
34+
35+
def test_service_account_key(service_account_key):
36+
request_func = get_auth_token_request_func(service_account_key=service_account_key)
37+
request = request_func()
38+
now = int(time.time())
39+
headers = jwt.get_unverified_header(request.jwt)
40+
parsed = jwt.decode(request.jwt, secret=service_account_key["public_key"], algorithms=['PS256'], verify=False)
41+
assert headers["typ"] == "JWT"
42+
assert headers["alg"] == "PS256"
43+
assert headers["kid"] == service_account_key["id"]
44+
45+
assert parsed["iss"] == service_account_key["service_account_id"]
46+
assert parsed["aud"] == "https://iam.api.cloud.yandex.net/iam/v1/tokens"
47+
assert now - 60 <= int(parsed["iat"]) <= now

yandexcloud/_auth_fabric.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import time
2+
from datetime import datetime
3+
4+
# jwt package depends on cryptography
5+
import cryptography
6+
import jwt
7+
8+
9+
from yandex.cloud.iam.v1.iam_token_service_pb2 import CreateIamTokenRequest
10+
11+
12+
def __validate_service_account_key(sa_key):
13+
obj_id = sa_key.get("id")
14+
sa_id = sa_key.get("service_account_id")
15+
private_key = sa_key.get("private_key")
16+
17+
if not obj_id:
18+
raise RuntimeError("Invalid Service Account Key: missing key object id.")
19+
20+
if not sa_id:
21+
raise RuntimeError("Invalid Service Account Key: missing service account id.")
22+
23+
if not private_key:
24+
raise RuntimeError("Invalid Service Account Key: missing private key.")
25+
26+
27+
def get_auth_token_request_func(token=None, service_account_key=None):
28+
if token and service_account_key:
29+
raise RuntimeError("Conflicting API credentials properties 'token' and 'service-account-key' are set.")
30+
31+
if token:
32+
return TokenAuth(token=token).get_token_request
33+
34+
__validate_service_account_key(service_account_key)
35+
return ServiceAccountAuth(service_account_key).get_token_request
36+
37+
38+
class TokenAuth:
39+
def __init__(self, token):
40+
self.__oauth_token = token
41+
42+
def get_token_request(self):
43+
return CreateIamTokenRequest(yandex_passport_oauth_token=self.__oauth_token)
44+
45+
46+
class ServiceAccountAuth:
47+
__SECONDS_IN_HOUR = 60. * 60.
48+
49+
def __init__(self, sa_key):
50+
self.__sa_key = sa_key
51+
52+
def get_token_request(self):
53+
return CreateIamTokenRequest(jwt=self.__prepare_request())
54+
55+
def __prepare_request(self):
56+
now = time.time()
57+
now_utc = datetime.utcfromtimestamp(now)
58+
exp_utc = datetime.utcfromtimestamp(now + self.__SECONDS_IN_HOUR)
59+
payload = {
60+
"iss": self.__sa_key["service_account_id"],
61+
"aud": "https://iam.api.cloud.yandex.net/iam/v1/tokens",
62+
"iat": now_utc,
63+
"exp": exp_utc,
64+
}
65+
66+
headers = {
67+
"typ": "JWT",
68+
"alg": "PS256",
69+
"kid": self.__sa_key["id"],
70+
}
71+
72+
return jwt.encode(payload, self.__sa_key["private_key"], algorithm="PS256", headers=headers)

yandexcloud/_auth_plugin.py

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,16 @@
1+
import grpc
12

2-
import threading
3-
from concurrent import futures
43
from datetime import datetime
5-
6-
import grpc
74
from six.moves.urllib.parse import urlparse
85

9-
from yandex.cloud.iam.v1.iam_token_service_pb2 import CreateIamTokenRequest
106
from yandex.cloud.iam.v1.iam_token_service_pb2_grpc import IamTokenServiceStub
117

128
TIMEOUT_SECONDS = 20
139

1410

1511
class Credentials(grpc.AuthMetadataPlugin):
16-
def __init__(self, oauth_token, lazy_channel):
17-
self._oauth_token = oauth_token
12+
def __init__(self, token_request_func, lazy_channel):
13+
self.__token_request_func = token_request_func
1814
self._lazy_channel = lazy_channel
1915
self._channel = None
2016
self._cached_iam_token = None
@@ -28,17 +24,15 @@ def __call__(self, context, callback):
2824

2925
def _call(self, context, callback):
3026
u = urlparse(context.service_url)
31-
if u.path == '/yandex.cloud.iam.v1.IamTokenService' or \
32-
u.path == '/yandex.cloud.endpoint.ApiEndpointService':
27+
if u.path == '/yandex.cloud.iam.v1.IamTokenService' or u.path == '/yandex.cloud.endpoint.ApiEndpointService':
3328
callback(None, None)
3429
return
3530

3631
if not self._channel:
3732
self._channel = self._lazy_channel()
3833

3934
if self._cached_iam_token is None or not self._fresh():
40-
token_future = IamTokenServiceStub(self._channel).Create.future(CreateIamTokenRequest(
41-
yandex_passport_oauth_token=self._oauth_token))
35+
token_future = IamTokenServiceStub(self._channel).Create.future(self.__token_request_func())
4236
token_future.add_done_callback(self.create_done_callback(callback))
4337
return
4438

yandexcloud/_channels.py

Lines changed: 8 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,27 +5,19 @@
55
from yandex.cloud.endpoint.api_endpoint_service_pb2 import ListApiEndpointsRequest
66

77
from yandexcloud import _auth_plugin
8-
9-
10-
def _fill_defaults(opts):
11-
opts['root_certificates'] = opts.get('root_certificates')
12-
opts['private_key'] = opts.get('private_key')
13-
opts['certificate_chain'] = opts.get('certificate_chain')
14-
opts['endpoint'] = opts.get('endpoint', 'api.cloud.yandex.net')
8+
from yandexcloud._auth_fabric import get_auth_token_request_func
159

1610

1711
class Channels(object):
1812
def __init__(self, **kwargs):
19-
opts = {x: y for x, y in kwargs.items()}
20-
_fill_defaults(opts)
21-
2213
self._channel_creds = grpc.ssl_channel_credentials(
23-
root_certificates=opts['root_certificates'],
24-
private_key=opts['private_key'],
25-
certificate_chain=opts['certificate_chain'],
14+
root_certificates=kwargs.get('root_certificates'),
15+
private_key=kwargs.get('private_key'),
16+
certificate_chain=kwargs.get('certificate_chain'),
2617
)
27-
self._endpoint = opts['endpoint']
28-
self._token = opts['token']
18+
self._endpoint = kwargs.get('endpoint', 'api.cloud.yandex.net')
19+
self._token_request_func = get_auth_token_request_func(token=kwargs.get("token"),
20+
service_account_key=kwargs.get("service_account_key"))
2921

3022
self._unauthenticated_channel = None
3123
self._channels = None
@@ -40,7 +32,7 @@ def channel(self, endpoint):
4032
endpoints = resp.endpoints
4133

4234
plugin = _auth_plugin.Credentials(
43-
self._token, lambda: self._channels["iam"])
35+
self._token_request_func, lambda: self._channels["iam"])
4436
call_creds = grpc.metadata_call_credentials(plugin)
4537
creds = grpc.composite_channel_credentials(
4638
self._channel_creds, call_creds)

0 commit comments

Comments
 (0)