|
78 | 78 |
|
79 | 79 | class Type(enum.Enum): |
80 | 80 | AUTHORIZED_USER = 'authorized_user' |
| 81 | + EXTERNAL_ACCOUNT = 'external_account' |
81 | 82 | GCE_METADATA = 'gce_metadata' |
82 | | - SERVICE_ACCOUNT = 'service_account' |
83 | 83 | IMPERSONATED_SERVICE_ACCOUNT = 'impersonated_service_account' |
| 84 | + SERVICE_ACCOUNT = 'service_account' |
84 | 85 |
|
85 | 86 |
|
86 | 87 | def get_service_data( |
@@ -184,6 +185,18 @@ def __init__( |
184 | 185 | self.service_data = get_service_data(service_file) |
185 | 186 | if self.service_data: |
186 | 187 | 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 | + ) |
187 | 200 | self.token_uri = self.service_data.get( |
188 | 201 | 'token_uri', 'https://oauth2.googleapis.com/token', |
189 | 202 | ) |
@@ -366,6 +379,103 @@ async def _refresh_source_authorized_user( |
366 | 379 | return TokenResponse(value=str(content['access_token']), |
367 | 380 | expires_in=int(content['expires_in'])) |
368 | 381 |
|
| 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 | + |
369 | 479 | async def _refresh_gce_metadata(self, timeout: int) -> TokenResponse: |
370 | 480 | resp = await self.session.get( |
371 | 481 | url=self.token_uri, headers=GCE_METADATA_HEADERS, timeout=timeout, |
@@ -433,13 +543,15 @@ async def _impersonate(self, token: TokenResponse, |
433 | 543 | async def refresh(self, *, timeout: int) -> TokenResponse: |
434 | 544 | if self.token_type == Type.AUTHORIZED_USER: |
435 | 545 | 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) |
436 | 548 | elif self.token_type == Type.GCE_METADATA: |
437 | 549 | 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) |
440 | 550 | elif self.token_type == Type.IMPERSONATED_SERVICE_ACCOUNT: |
441 | 551 | # impersonation requires a source authorized user |
442 | 552 | 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) |
443 | 555 | else: |
444 | 556 | raise Exception(f'unsupported token type {self.token_type}') |
445 | 557 |
|
|
0 commit comments