1919from typing import Optional
2020
2121import google .auth
22+ from google .auth import exceptions as google_auth_exceptions
2223from google .auth .transport .requests import Request
2324from google .oauth2 import service_account
2425import google .oauth2 .credentials
2728from .....auth .auth_credential import AuthCredentialTypes
2829from .....auth .auth_credential import HttpAuth
2930from .....auth .auth_credential import HttpCredentials
31+ from .....auth .auth_credential import ServiceAccount
3032from .....auth .auth_schemes import AuthScheme
3133from .base_credential_exchanger import AuthCredentialMissingError
3234from .base_credential_exchanger import BaseAuthCredentialExchanger
@@ -38,59 +40,142 @@ class ServiceAccountCredentialExchanger(BaseAuthCredentialExchanger):
3840 Uses the default service credential if `use_default_credential = True`.
3941 Otherwise, uses the service account credential provided in the auth
4042 credential.
43+
44+ Supports exchanging for either an access token (default) or an ID token
45+ when ``ServiceAccount.use_id_token`` is True. ID tokens are required for
46+ service-to-service authentication with Cloud Run, Cloud Functions, and
47+ other services that verify caller identity.
4148 """
4249
4350 def exchange_credential (
4451 self ,
4552 auth_scheme : AuthScheme ,
4653 auth_credential : Optional [AuthCredential ] = None ,
4754 ) -> AuthCredential :
48- """Exchanges the service account auth credential for an access token.
55+ """Exchanges the service account auth credential for a token.
4956
5057 If auth_credential contains a service account credential, it will be used
51- to fetch an access token. Otherwise, the default service credential will be
52- used for fetching an access token.
58+ to fetch a token. Otherwise, the default service credential will be
59+ used for fetching a token.
60+
61+ When ``service_account.use_id_token`` is True, an ID token is fetched
62+ using the configured ``audience``. This is required for authenticating
63+ to Cloud Run, Cloud Functions, and similar services.
5364
5465 Args:
5566 auth_scheme: The auth scheme.
5667 auth_credential: The auth credential.
5768
5869 Returns:
59- An AuthCredential in HTTPBearer format, containing the access token.
70+ An AuthCredential in HTTPBearer format, containing the token.
6071 """
61- if (
62- auth_credential is None
63- or auth_credential .service_account is None
64- or (
65- auth_credential .service_account .service_account_credential is None
66- and not auth_credential .service_account .use_default_credential
67- )
68- ):
72+ if auth_credential is None or auth_credential .service_account is None :
6973 raise AuthCredentialMissingError (
70- "Service account credentials are missing. Please provide them, or set "
71- " `use_default_credential = True` to use application default"
74+ "Service account credentials are missing. Please provide them, or"
75+ " set `use_default_credential = True` to use application default"
7276 " credential in a hosted service like Cloud Run."
7377 )
7478
79+ sa_config = auth_credential .service_account
80+
81+ if sa_config .use_id_token :
82+ return self ._exchange_for_id_token (sa_config )
83+
84+ return self ._exchange_for_access_token (sa_config )
85+
86+ def _exchange_for_id_token (self , sa_config : ServiceAccount ) -> AuthCredential :
87+ """Exchanges the service account credential for an ID token.
88+
89+ Args:
90+ sa_config: The service account configuration.
91+
92+ Returns:
93+ An AuthCredential in HTTPBearer format containing the ID token.
94+
95+ Raises:
96+ AuthCredentialMissingError: If token exchange fails.
97+ """
98+ # audience and credential presence are validated by the ServiceAccount
99+ # model_validator at construction time.
75100 try :
76- if auth_credential .service_account .use_default_credential :
77- credentials , project_id = google .auth .default (
78- scopes = ["https://www.googleapis.com/auth/cloud-platform" ],
101+ if sa_config .use_default_credential :
102+ from google .oauth2 import id_token as oauth2_id_token
103+
104+ request = Request ()
105+ token = oauth2_id_token .fetch_id_token (request , sa_config .audience )
106+ else :
107+ # Guaranteed non-None by ServiceAccount model_validator.
108+ assert sa_config .service_account_credential is not None
109+ credentials = (
110+ service_account .IDTokenCredentials .from_service_account_info (
111+ sa_config .service_account_credential .model_dump (),
112+ target_audience = sa_config .audience ,
113+ )
79114 )
80- quota_project_id = (
81- getattr (credentials , "quota_project_id" , None ) or project_id
115+ credentials .refresh (Request ())
116+ token = credentials .token
117+
118+ return AuthCredential (
119+ auth_type = AuthCredentialTypes .HTTP ,
120+ http = HttpAuth (
121+ scheme = "bearer" ,
122+ credentials = HttpCredentials (token = token ),
123+ ),
124+ )
125+
126+ # ValueError is raised by google-auth when service account JSON is
127+ # missing required fields (e.g. client_email, private_key), or when
128+ # fetch_id_token cannot determine credentials from the environment.
129+ except (google_auth_exceptions .GoogleAuthError , ValueError ) as e :
130+ raise AuthCredentialMissingError (
131+ f"Failed to exchange service account for ID token: { e } "
132+ ) from e
133+
134+ def _exchange_for_access_token (
135+ self , sa_config : ServiceAccount
136+ ) -> AuthCredential :
137+ """Exchanges the service account credential for an access token.
138+
139+ Args:
140+ sa_config: The service account configuration.
141+
142+ Returns:
143+ An AuthCredential in HTTPBearer format containing the access token.
144+
145+ Raises:
146+ AuthCredentialMissingError: If scopes are missing for explicit
147+ credentials or token exchange fails.
148+ """
149+ if not sa_config .use_default_credential and not sa_config .scopes :
150+ raise AuthCredentialMissingError (
151+ "scopes are required when using explicit service account credentials"
152+ " for access token exchange."
153+ )
154+
155+ try :
156+ if sa_config .use_default_credential :
157+ scopes = (
158+ sa_config .scopes
159+ if sa_config .scopes
160+ else ["https://www.googleapis.com/auth/cloud-platform" ]
161+ )
162+ credentials , project_id = google .auth .default (
163+ scopes = scopes ,
82164 )
165+ quota_project_id = credentials .quota_project_id or project_id
83166 else :
84- config = auth_credential .service_account
167+ # Guaranteed non-None by ServiceAccount model_validator.
168+ assert sa_config .service_account_credential is not None
85169 credentials = service_account .Credentials .from_service_account_info (
86- config .service_account_credential .model_dump (), scopes = config .scopes
170+ sa_config .service_account_credential .model_dump (),
171+ scopes = sa_config .scopes ,
87172 )
88173 quota_project_id = None
89174
90175 credentials .refresh (Request ())
91176
92- updated_credential = AuthCredential (
93- auth_type = AuthCredentialTypes .HTTP , # Store as a bearer token
177+ return AuthCredential (
178+ auth_type = AuthCredentialTypes .HTTP ,
94179 http = HttpAuth (
95180 scheme = "bearer" ,
96181 credentials = HttpCredentials (token = credentials .token ),
@@ -101,9 +186,10 @@ def exchange_credential(
101186 else None ,
102187 ),
103188 )
104- return updated_credential
105189
106- except Exception as e :
190+ # ValueError is raised by google-auth when service account JSON is
191+ # missing required fields (e.g. client_email, private_key).
192+ except (google_auth_exceptions .GoogleAuthError , ValueError ) as e :
107193 raise AuthCredentialMissingError (
108194 f"Failed to exchange service account token: { e } "
109195 ) from e
0 commit comments