Skip to content

Commit 361cec6

Browse files
feat(auth): external credential type in auth (talkiq#906)
Co-Authored-By: Prahathess Rengasamy <prahathess@gmail.com>
1 parent 7ddafa2 commit 361cec6

4 files changed

Lines changed: 418 additions & 3 deletions

File tree

auth/README.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@ against Google Cloud. The other ``gcloud-aio-*`` package components accept a
1919
these components or define one for each. Each component corresponds to a given
2020
Google Cloud service and each service requires various "`scopes`_".
2121

22+
The library supports multiple authentication methods:
23+
- Service account credentials
24+
- Authorized user credentials
25+
- GCE metadata credentials
26+
- Impersonated service account credentials
27+
- External account credentials (for workload identity federation)
28+
2229
|pypi| |pythons|
2330

2431
Installation

auth/gcloud/aio/auth/__init__.py

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,166 @@
7373
the **roles/iam.serviceAccountTokenCreator** role on the service account that
7474
is specified in the ``target_principal``.
7575
76+
Basic Usage
77+
~~~~~~~~~~~
78+
79+
.. code-block:: python
80+
81+
from gcloud.aio.auth import Token
82+
83+
# Use default credentials (searches for credentials in standard locations)
84+
token = Token()
85+
access_token = await token.get()
86+
87+
# Use a specific service account file
88+
token = Token(service_file='path/to/service-account.json')
89+
access_token = await token.get()
90+
91+
# Use a custom session
92+
import aiohttp
93+
async with aiohttp.ClientSession() as session:
94+
token = Token(session=session)
95+
access_token = await token.get()
96+
97+
Service Account Authentication
98+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
99+
100+
.. code-block:: python
101+
102+
from gcloud.aio.auth import Token
103+
104+
# Use service account with specific scopes
105+
token = Token(
106+
service_file='path/to/service-account.json',
107+
scopes=['https://www.googleapis.com/auth/cloud-platform']
108+
)
109+
access_token = await token.get()
110+
111+
Authorized User Authentication
112+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
113+
114+
.. code-block:: python
115+
116+
from gcloud.aio.auth import Token
117+
118+
# Use authorized user credentials (e.g., from gcloud auth application-default login)
119+
token = Token(service_file='~/.config/gcloud/application_default_credentials.json')
120+
access_token = await token.get()
121+
122+
GCE Metadata Authentication
123+
~~~~~~~~~~~~~~~~~~~~~~~~~~
124+
125+
.. code-block:: python
126+
127+
from gcloud.aio.auth import Token
128+
129+
# When running on GCE, the metadata server is used automatically
130+
token = Token()
131+
access_token = await token.get()
132+
133+
Service Account Impersonation
134+
~~~~~~~~~~~~~~~~~~~~~~~~~~~
135+
136+
.. code-block:: python
137+
138+
from gcloud.aio.auth import Token
139+
140+
# Impersonate a service account
141+
token = Token(
142+
service_file='path/to/source-credentials.json',
143+
target_principal='target-service@project.iam.gserviceaccount.com',
144+
scopes=['https://www.googleapis.com/auth/cloud-platform']
145+
)
146+
access_token = await token.get()
147+
148+
# With delegation chain
149+
token = Token(
150+
service_file='path/to/source-credentials.json',
151+
target_principal='target-service@project.iam.gserviceaccount.com',
152+
delegates=['delegate-service@project.iam.gserviceaccount.com'],
153+
scopes=['https://www.googleapis.com/auth/cloud-platform']
154+
)
155+
access_token = await token.get()
156+
157+
External Account Credentials
158+
---------------------------
159+
160+
The library supports external account credentials for workload identity
161+
federation. This allows you to use credentials from external identity providers
162+
(like AWS, Azure, or OIDC) to access Google Cloud resources.
163+
164+
Example configuration file:
165+
166+
.. code-block:: json
167+
168+
{
169+
"type": "external_account",
170+
"audience": "//iam.googleapis.com/projects/123456/locations/global/workloadIdentityPools/pool/subject",
171+
"subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
172+
"token_url": "https://sts.googleapis.com/v1/token",
173+
"credential_source": {
174+
"type": "url",
175+
"url": "http://169.254.169.254/metadata/identity/oauth2/token",
176+
"headers": {
177+
"Metadata": "true"
178+
}
179+
}
180+
}
181+
182+
Usage:
183+
184+
.. code-block:: python
185+
186+
from gcloud.aio.auth import Token
187+
188+
# Basic usage with external account credentials
189+
token = Token(service_file='path/to/external_account_credentials.json')
190+
access_token = await token.get()
191+
192+
# With specific scopes
193+
token = Token(
194+
service_file='path/to/external_account_credentials.json',
195+
scopes=['https://www.googleapis.com/auth/cloud-platform']
196+
)
197+
access_token = await token.get()
198+
199+
The library supports multiple credential source types:
200+
- URL: Fetches token from a URL endpoint (supports both plaintext and JSON)
201+
- File: Reads token from a file
202+
- Environment: Gets token from an environment variable
203+
204+
IAP Token Usage
205+
~~~~~~~~~~~~~
206+
207+
.. code-block:: python
208+
209+
from gcloud.aio.auth import IapToken
210+
211+
# Basic IAP token usage
212+
iap_token = IapToken('https://your-iap-secured-service.com')
213+
id_token = await iap_token.get()
214+
215+
# With service account impersonation
216+
iap_token = IapToken(
217+
'https://your-iap-secured-service.com',
218+
impersonating_service_account='service@project.iam.gserviceaccount.com'
219+
)
220+
id_token = await iap_token.get()
221+
222+
IAM Client Usage
223+
~~~~~~~~~~~~~~
224+
225+
.. code-block:: python
226+
227+
from gcloud.aio.auth import IamClient
228+
229+
# List public keys
230+
client = IamClient()
231+
pubkeys = await client.list_public_keys()
232+
233+
# Get a specific public key
234+
key = await client.get_public_key('key-id')
235+
76236
CLI
77237
---
78238

auth/gcloud/aio/auth/token.py

Lines changed: 115 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,10 @@
7878

7979
class Type(enum.Enum):
8080
AUTHORIZED_USER = 'authorized_user'
81+
EXTERNAL_ACCOUNT = 'external_account'
8182
GCE_METADATA = 'gce_metadata'
82-
SERVICE_ACCOUNT = 'service_account'
8383
IMPERSONATED_SERVICE_ACCOUNT = 'impersonated_service_account'
84+
SERVICE_ACCOUNT = 'service_account'
8485

8586

8687
def get_service_data(
@@ -184,6 +185,18 @@ def __init__(
184185
self.service_data = get_service_data(service_file)
185186
if self.service_data:
186187
self.token_type = Type(self.service_data['type'])
188+
if self.token_type == Type.EXTERNAL_ACCOUNT:
189+
required_fields = {
190+
'audience',
191+
'credential_source',
192+
'subject_token_type',
193+
'token_url',
194+
}
195+
if required_fields - self.service_data.keys():
196+
raise ValueError(
197+
'external_account credentials missing required '
198+
f"fields: {', '.join(required_fields)}"
199+
)
187200
self.token_uri = self.service_data.get(
188201
'token_uri', 'https://oauth2.googleapis.com/token',
189202
)
@@ -366,6 +379,103 @@ async def _refresh_source_authorized_user(
366379
return TokenResponse(value=str(content['access_token']),
367380
expires_in=int(content['expires_in']))
368381

382+
async def _refresh_external_account(self, timeout: int) -> TokenResponse:
383+
if not self.service_data:
384+
raise ValueError('external_account auth requires service_data')
385+
386+
credential_source = self.service_data['credential_source']
387+
subject_token = await self._get_subject_token(
388+
credential_source, timeout,
389+
)
390+
391+
# exchange the subject token for a Google access token
392+
data = {
393+
'audience': self.service_data['audience'],
394+
'grant_type': 'urn:ietf:params:oauth:grant-type:token-exchange',
395+
'requested_token_type': (
396+
'urn:ietf:params:oauth:token-type:access_token'
397+
),
398+
'subject_token': subject_token,
399+
'subject_token_type': self.service_data['subject_token_type'],
400+
}
401+
# add optional service account impersonation if configured
402+
if self.service_data.get('service_account_impersonation_url'):
403+
data['service_account_impersonation_url'] = self.service_data[
404+
'service_account_impersonation_url'
405+
]
406+
# add optional client ID and secret if configured
407+
if self.service_data.get('client_id'):
408+
data['client_id'] = self.service_data['client_id']
409+
if self.service_data.get('client_secret'):
410+
data['client_secret'] = self.service_data['client_secret']
411+
# add scopes if configured
412+
if self.scopes:
413+
data['scope'] = ' '.join(self.scopes)
414+
415+
resp = await self.session.post(
416+
self.service_data['token_url'],
417+
data=urlencode(data),
418+
headers=REFRESH_HEADERS,
419+
timeout=timeout,
420+
)
421+
try:
422+
data = await resp.json()
423+
except (AttributeError, TypeError):
424+
data = json.loads(await resp.text())
425+
426+
return TokenResponse(
427+
value=data['access_token'],
428+
expires_in=data.get('expires_in', self.default_token_ttl),
429+
)
430+
431+
async def _get_subject_token(
432+
self, credential_source: dict[str, Any], timeout: int
433+
) -> str:
434+
# pylint: disable=too-complex
435+
source_type = credential_source.get('type')
436+
if not source_type:
437+
# TODO: looks like sometimes the type can be found elsewhere or
438+
# needs to be infered.
439+
# https://github.com/talkiq/gcloud-aio/pull/906/changes#r2206959538
440+
raise ValueError('credential_source is missing type field')
441+
442+
if source_type == 'url':
443+
url = credential_source['url']
444+
format_ = credential_source.get('format', {})
445+
format_type = format_.get('type', 'text')
446+
447+
resp = await self.session.get(
448+
url,
449+
headers=credential_source.get('headers', {}),
450+
timeout=timeout,
451+
)
452+
453+
if format_type == 'json':
454+
try:
455+
data = await resp.json()
456+
except (AttributeError, TypeError):
457+
data = json.loads(await resp.text())
458+
459+
token: str = data[format_['subject_token_field_name']]
460+
return token
461+
462+
try:
463+
return await resp.text()
464+
except (AttributeError, TypeError):
465+
return str(resp.text)
466+
467+
if source_type == 'file':
468+
try:
469+
with open(credential_source['file'], encoding='utf-8') as f:
470+
return f.read().strip()
471+
except Exception as e:
472+
raise ValueError('failed to read subject token file') from e
473+
474+
if source_type == 'environment':
475+
return os.environ[credential_source['environment_id']]
476+
477+
raise ValueError(f'unsupported credential_source type: {source_type}')
478+
369479
async def _refresh_gce_metadata(self, timeout: int) -> TokenResponse:
370480
resp = await self.session.get(
371481
url=self.token_uri, headers=GCE_METADATA_HEADERS, timeout=timeout,
@@ -433,13 +543,15 @@ async def _impersonate(self, token: TokenResponse,
433543
async def refresh(self, *, timeout: int) -> TokenResponse:
434544
if self.token_type == Type.AUTHORIZED_USER:
435545
resp = await self._refresh_authorized_user(timeout=timeout)
546+
elif self.token_type == Type.EXTERNAL_ACCOUNT:
547+
resp = await self._refresh_external_account(timeout=timeout)
436548
elif self.token_type == Type.GCE_METADATA:
437549
resp = await self._refresh_gce_metadata(timeout=timeout)
438-
elif self.token_type == Type.SERVICE_ACCOUNT:
439-
resp = await self._refresh_service_account(timeout=timeout)
440550
elif self.token_type == Type.IMPERSONATED_SERVICE_ACCOUNT:
441551
# impersonation requires a source authorized user
442552
resp = await self._refresh_source_authorized_user(timeout=timeout)
553+
elif self.token_type == Type.SERVICE_ACCOUNT:
554+
resp = await self._refresh_service_account(timeout=timeout)
443555
else:
444556
raise Exception(f'unsupported token type {self.token_type}')
445557

0 commit comments

Comments
 (0)