Skip to content

Commit 9dbde43

Browse files
committed
Adding service account impersonation (waiting on #76)
This PR adds a new `ServiceAccount` format that takes credentials from `source_credentials: ServiceAccount` and then makes a request to get a service account token using those credentials. This also adds the ability to parse the token format created by `gcloud auth application-default login --impersonate-service-account <service account>`
1 parent 925dbba commit 9dbde43

6 files changed

+277
-7
lines changed

Cargo.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ rustls-pemfile = "1.0.0"
2626
serde = { version = "1.0", features = ["derive", "rc"] }
2727
serde_json = "1.0"
2828
thiserror = "1.0"
29-
time = { version = "0.3.5", features = ["serde"] }
29+
time = { version = "0.3.5", features = ["serde", "parsing"] }
3030
tokio = { version = "1.1", features = ["fs", "sync"] }
3131
tracing = "0.1.29"
3232
tracing-futures = "0.2.5"

src/error.rs

+4
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,10 @@ pub enum Error {
9696
#[error("Failed to parse output of GCloud")]
9797
GCloudParseError,
9898

99+
/// Currently, nested service account impersonation is not supported
100+
#[error("Nested impersonation is not supported")]
101+
NestedImpersonation,
102+
99103
/// Represents all other cases of `std::io::Error`.
100104
#[error(transparent)]
101105
IOError(#[from] std::io::Error),

src/flexible_credential_source.rs

+104-2
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
use std::path::{Path, PathBuf};
22

3-
use serde::{Deserialize, Serialize};
3+
use serde::Deserialize;
44
use tokio::fs;
55

66
use crate::{
77
authentication_manager::ServiceAccount,
88
custom_service_account::ApplicationCredentials,
99
default_authorized_user::{ConfigDefaultCredentials, UserCredentials},
10+
service_account_impersonation::ImpersonatedServiceAccount,
1011
types::HyperClient,
1112
CustomServiceAccount, Error,
1213
};
@@ -15,7 +16,7 @@ use crate::{
1516
// https://github.com/golang/oauth2/blob/a835fc4358f6852f50c4c5c33fddcd1adade5b0a/google/google.go#L158
1617
// Currently not implementing external account credentials
1718
// Currently not implementing impersonating service accounts (coming soon !)
18-
#[derive(Serialize, Deserialize, Debug)]
19+
#[derive(Deserialize, Debug)]
1920
#[serde(tag = "type", rename_all = "snake_case")]
2021
pub(crate) enum FlexibleCredentialSource {
2122
// This credential parses the `key.json` file created when running
@@ -24,6 +25,9 @@ pub(crate) enum FlexibleCredentialSource {
2425
// This credential parses the `~/.config/gcloud/application_default_credentials.json` file
2526
// created when running `gcloud auth application-default login`
2627
AuthorizedUser(UserCredentials),
28+
// This credential parses the `~/.config/gcloud/application_default_credentials.json` file
29+
// created when running `gcloud auth application-default login --impersonate-service-account <service account>`
30+
ImpersonatedServiceAccount(ImpersonatedServiceAccountCredentials),
2731
}
2832

2933
impl FlexibleCredentialSource {
@@ -62,6 +66,30 @@ impl FlexibleCredentialSource {
6266
ConfigDefaultCredentials::from_user_credentials(creds, client).await?;
6367
Ok(Box::new(service_account))
6468
}
69+
FlexibleCredentialSource::ImpersonatedServiceAccount(creds) => {
70+
let source_creds: Box<dyn ServiceAccount> = match *creds.source_credentials {
71+
FlexibleCredentialSource::AuthorizedUser(creds) => {
72+
let service_account =
73+
ConfigDefaultCredentials::from_user_credentials(creds, client).await?;
74+
Box::new(service_account)
75+
}
76+
FlexibleCredentialSource::ServiceAccount(creds) => {
77+
let service_account = CustomServiceAccount::new(creds)?;
78+
Box::new(service_account)
79+
}
80+
FlexibleCredentialSource::ImpersonatedServiceAccount(_) => {
81+
return Err(Error::NestedImpersonation)
82+
}
83+
};
84+
85+
let service_account = ImpersonatedServiceAccount::new(
86+
source_creds,
87+
creds.service_account_impersonation_url,
88+
creds.delegates,
89+
);
90+
91+
Ok(Box::new(service_account))
92+
}
6593
}
6694
}
6795

@@ -76,6 +104,17 @@ impl FlexibleCredentialSource {
76104
}
77105
}
78106

107+
// This credential uses the `source_credentials` to get a token
108+
// and then uses that token to get a token impersonating the service
109+
// account specified by `service_account_impersonation_url`.
110+
// refresh logic https://github.com/golang/oauth2/blob/a835fc4358f6852f50c4c5c33fddcd1adade5b0a/google/internal/externalaccount/impersonate.go#L57
111+
#[derive(Deserialize, Debug)]
112+
pub(crate) struct ImpersonatedServiceAccountCredentials {
113+
service_account_impersonation_url: String,
114+
source_credentials: Box<FlexibleCredentialSource>,
115+
delegates: Vec<String>,
116+
}
117+
79118
#[cfg(test)]
80119
mod tests {
81120
use crate::{flexible_credential_source::FlexibleCredentialSource, types};
@@ -192,4 +231,67 @@ mod tests {
192231
);
193232
}
194233
}
234+
235+
#[tokio::test]
236+
async fn test_parse_impersonating_service_account() {
237+
let impersonate_from_user_creds = r#"{
238+
"delegates": [],
239+
"service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test_account@test_project.iam.gserviceaccount.com:generateAccessToken",
240+
"source_credentials": {
241+
"client_id": "***id***.apps.googleusercontent.com",
242+
"client_secret": "***secret***",
243+
"refresh_token": "***refresh***",
244+
"type": "authorized_user",
245+
"quota_project_id": "test_project"
246+
},
247+
"type": "impersonated_service_account"
248+
}"#;
249+
250+
let cred_source: FlexibleCredentialSource =
251+
serde_json::from_str(impersonate_from_user_creds).expect("Valid creds to parse");
252+
253+
assert!(matches!(
254+
cred_source,
255+
FlexibleCredentialSource::ImpersonatedServiceAccount(_)
256+
));
257+
258+
let impersonate_from_service_key = r#"{
259+
"delegates": [],
260+
"service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test_account@test_project.iam.gserviceaccount.com:generateAccessToken",
261+
"source_credentials": {
262+
"private_key_id": "268f54e43a1af97cfc71731688434f45aca15c8b",
263+
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC5M5y3WwsRk8NX\npF9fKaZukNspot9Ecmk1PAkupcHLKVhalwPxU4sMNWXgM9H2LTWSvvyOT//rDQpn\n3SGYri/lMhzb4lI8h10E7k6zyFQUPujxkXFBkMOzhIDUgtiiht0WvIw6M8nbaPqI\nxn/aYmPsFhvJfKCthYAt2UUz+D3enI9QjCuhic8iSMnvKT8m0QkOG2eALYGUaLF1\ngRkbV4BiBUGZfXfNEBdux3Wf4kNUau32LA0XotomlvNvf1oH77v5Hc1R/KMMIk5F\nJWVBuAr4jwkN9hwtOozpJ/52wSpddxsZuj+0nP1a3f0UyvrmMnuwszardPK39BoH\nJ+5+HZM3AgMBAAECggEADrHZrXK73hkrVrjkGFjlq8Ayo4sYzAWH84Ff+SONzODq\n8cUpuuw2DDHwc2mpLy9HIO2mfGQ8mhneyX7yO3sWscjYIVpDzCmxZ8LA2+L5SOH0\n+bXglqM14/iPgE0hg0PQJw2u0q9pRM9/kXquilVkOEdIzSPmW95L3Vdv9j+sKQ2A\nOL23l4dsaG4+i1lWRBKiGsLh1kB9FRnm4BzcOxd3WGooy7L1/jo9BoYRss1YABls\nmmyZ9f7r28zjclhpOBkE3OXX0zNbp4yIu1O1Bt9X2p87EOuYqlFA5eEvDbiTPZbk\n6wKEX3BPUkeIo8OaGvsGhHCWx0lv/sDPw/UofycOgQKBgQD4BD059aXEV13Byc5D\nh8LQSejjeM/Vx+YeCFI66biaIOvUs+unyxkH+qxXTuW6AgOgcvrJo93xkyAZ9SeR\nc6Vj9g5mZ5vqSJz5Hg8h8iZBAYtf40qWq0pHcmUIm2Z9LvrG5ZFHU5EEcCtLyBVS\nAv+pLLLf3OsAkJuuqTAgygBbOwKBgQC/KcBa9sUg2u9qIpq020UOW/n4KFWhSJ8h\ngXqqmjOnPqmDc5AnYg1ZdYdqSSgdiK8lJpRL/S2UjYUQp3H+56z0eK/b1iKM51n+\n6D80nIxWeKJ+n7VKI7cBXwc/KokaXgkz0It2UEZSlhPUMImnYcOvGIZ7cMr3Q6mf\n6FwD15UQNQKBgQDyAsDz454DvvS/+noJL1qMAPL9tI+pncwQljIXRqVZ0LIO9hoH\nu4kLXjH5aAWGwhxj3o6VYA9cgSIb8jrQFbbXmexnRMbBkGWMOSavCykE2cr0oEfS\nSgbLPPcVtP4HPWZ72tsubH7fg8zbv7v+MOrkW7eX9mxiOrmPb4yFElfSrQKBgA7y\nMLvr91WuSHG/6uChFDEfN9gTLz7A8tAn03NrQwace5xveKHbpLeN3NyOg7hra2Y4\nMfgO/3VR60l2Dg+kBX3HwdgqUeE6ZWrstaRjaQWJwQqtafs196T/zQ0/QiDxoT6P\n25eQhy8F1N8OPHT9y9Lw0/LqyrOycpyyCh+yx1DRAoGAJ/6dlhyQnwSfMAe3mfRC\noiBQG6FkyoeXHHYcoQ/0cSzwp0BwBlar1Z28P7KTGcUNqV+YfK9nF47eoLaTLCmG\nG5du0Ds6m2Eg0sOBBqXHnw6R1PC878tgT/XokNxIsVlF5qRz88q7Rn0J1lzB7+Tl\n2HSAcyIUcmr0gxlhRmC2Jq4=\n-----END PRIVATE KEY-----\n",
264+
"client_email": "[email protected]",
265+
"client_id": "gopher.apps.googleusercontent.com",
266+
"token_uri": "https://accounts.google.com/o/gophers/token",
267+
"type": "service_account",
268+
"audience": "https://testservice.googleapis.com/",
269+
"project_id": "test_project"
270+
},
271+
"type": "impersonated_service_account"
272+
}"#;
273+
274+
let cred_source: FlexibleCredentialSource =
275+
serde_json::from_str(impersonate_from_service_key).expect("Valid creds to parse");
276+
277+
assert!(matches!(
278+
cred_source,
279+
FlexibleCredentialSource::ImpersonatedServiceAccount(_)
280+
));
281+
282+
let client = types::client();
283+
let creds = cred_source
284+
.try_into_service_account(&client)
285+
.await
286+
.expect("Valid creds to parse");
287+
288+
assert_eq!(
289+
creds
290+
.project_id(&client)
291+
.await
292+
.expect("Project ID to be present"),
293+
"test_project".to_string(),
294+
"Project ID should be parsed"
295+
);
296+
}
195297
}

src/lib.rs

+1
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ mod error;
9797
mod flexible_credential_source;
9898
mod gcloud_authorized_user;
9999
mod jwt;
100+
mod service_account_impersonation;
100101
mod types;
101102
mod util;
102103

src/service_account_impersonation.rs

+132
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
use async_trait::async_trait;
2+
use std::{collections::HashMap, sync::RwLock};
3+
4+
use hyper::header;
5+
use serde::Serialize;
6+
7+
use crate::{
8+
authentication_manager::ServiceAccount, gcloud_authorized_user::DEFAULT_TOKEN_DURATION,
9+
types::HyperClient, util::HyperExt, Error, Token,
10+
};
11+
12+
// This credential uses the `source_credentials` to get a token
13+
// and then uses that token to get a token impersonating the service
14+
// account specified by `service_account_impersonation_url`.
15+
// refresh logic https://github.com/golang/oauth2/blob/a835fc4358f6852f50c4c5c33fddcd1adade5b0a/google/internal/externalaccount/impersonate.go#L57
16+
//
17+
// In practice, the api currently referred to by `service_account_impersonation_url` is
18+
// https://cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/generateAccessToken
19+
pub(crate) struct ImpersonatedServiceAccount {
20+
service_account_impersonation_url: String,
21+
source_credentials: Box<dyn ServiceAccount>,
22+
delegates: Vec<String>,
23+
tokens: RwLock<HashMap<Vec<String>, Token>>,
24+
}
25+
26+
impl ImpersonatedServiceAccount {
27+
pub(crate) fn new(
28+
source_credentials: Box<dyn ServiceAccount>,
29+
service_account_impersonation_url: String,
30+
delegates: Vec<String>,
31+
) -> Self {
32+
Self {
33+
service_account_impersonation_url,
34+
source_credentials,
35+
delegates,
36+
tokens: RwLock::new(HashMap::new()),
37+
}
38+
}
39+
}
40+
41+
impl std::fmt::Debug for ImpersonatedServiceAccount {
42+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43+
f.debug_struct("ImpersonatedServiceAccount")
44+
.field(
45+
"service_account_impersonation_url",
46+
&self.service_account_impersonation_url,
47+
)
48+
.field("source_credentials", &"Box<dyn ServiceAccount>")
49+
.field("delegates", &self.delegates)
50+
.finish()
51+
}
52+
}
53+
54+
#[async_trait]
55+
impl ServiceAccount for ImpersonatedServiceAccount {
56+
async fn project_id(&self, hc: &HyperClient) -> Result<String, Error> {
57+
self.source_credentials.project_id(hc).await
58+
}
59+
60+
fn get_token(&self, scopes: &[&str]) -> Option<Token> {
61+
let key: Vec<_> = scopes.iter().map(|x| x.to_string()).collect();
62+
self.tokens.read().unwrap().get(&key).cloned()
63+
}
64+
65+
async fn refresh_token(&self, client: &HyperClient, scopes: &[&str]) -> Result<Token, Error> {
66+
let source_token = self
67+
.source_credentials
68+
.refresh_token(client, scopes)
69+
.await?;
70+
71+
// Then we do a request to get the impersonated token
72+
let lifetime_seconds = DEFAULT_TOKEN_DURATION.whole_seconds();
73+
#[derive(Serialize, Clone)]
74+
// Format from https://github.com/golang/oauth2/blob/a835fc4358f6852f50c4c5c33fddcd1adade5b0a/google/internal/externalaccount/impersonate.go#L21
75+
struct AccessTokenRequest {
76+
lifetime: String,
77+
#[serde(skip_serializing_if = "Vec::is_empty")]
78+
scope: Vec<String>,
79+
#[serde(skip_serializing_if = "Vec::is_empty")]
80+
delegates: Vec<String>,
81+
}
82+
83+
let request = AccessTokenRequest {
84+
lifetime: format!("{lifetime_seconds}s"),
85+
scope: scopes.iter().map(|s| s.to_string()).collect(),
86+
delegates: self.delegates.clone(),
87+
};
88+
let rqbody =
89+
serde_json::to_string(&request).expect("access token request failed to serialize");
90+
91+
let token_uri = self.service_account_impersonation_url.as_str();
92+
93+
let mut retries = 0;
94+
let response = loop {
95+
// We assume bearer tokens only. In the referenced code, other token types are possible
96+
// https://github.com/golang/oauth2/blob/a835fc4358f6852f50c4c5c33fddcd1adade5b0a/token.go#L84
97+
let request = hyper::Request::post(token_uri)
98+
.header(
99+
header::AUTHORIZATION,
100+
format!("Bearer {}", source_token.as_str()),
101+
)
102+
.header(header::CONTENT_TYPE, "application/json")
103+
.body(hyper::Body::from(rqbody.clone()))
104+
.unwrap();
105+
106+
tracing::debug!("requesting impersonation token from service account: {request:?}");
107+
let err = match client.request(request).await {
108+
// Early return when the request succeeds
109+
Ok(response) => break response,
110+
Err(err) => err,
111+
};
112+
113+
tracing::warn!(
114+
"Failed to refresh impersonation token with service token endpoint {token_uri}: {err}, trying again..."
115+
);
116+
retries += 1;
117+
if retries >= RETRY_COUNT {
118+
return Err(Error::OAuthConnectionError(err));
119+
}
120+
};
121+
122+
let token: Token = response.deserialize().await?;
123+
124+
let key = scopes.iter().map(|x| (*x).to_string()).collect();
125+
self.tokens.write().unwrap().insert(key, token.clone());
126+
127+
Ok(token)
128+
}
129+
}
130+
131+
/// How many times to attempt to fetch a token from the set credentials token endpoint.
132+
const RETRY_COUNT: u8 = 5;

src/types.rs

+35-4
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ use ring::{
77
rand::SystemRandom,
88
signature::{RsaKeyPair, RSA_PKCS1_SHA256},
99
};
10-
use serde::Deserializer;
10+
use serde::{de, Deserializer};
1111
use serde::{Deserialize, Serialize};
12+
use time::format_description::well_known::Iso8601;
1213
use time::{Duration, OffsetDateTime};
1314

1415
use crate::Error;
@@ -50,12 +51,17 @@ impl fmt::Debug for Token {
5051
}
5152
}
5253

54+
// InnerToken supports deserializing either the standard oauth `expires_in: seconds` format or
55+
// the impersonation token that is returned from here
56+
// https://cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/generateAccessToken
5357
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
5458
struct InnerToken {
59+
#[serde(alias = "accessToken")]
5560
access_token: String,
5661
#[serde(
5762
deserialize_with = "deserialize_time",
58-
rename(deserialize = "expires_in")
63+
rename(deserialize = "expires_in"),
64+
alias = "expireTime"
5965
)]
6066
expires_at: OffsetDateTime,
6167
}
@@ -143,8 +149,26 @@ fn deserialize_time<'de, D>(deserializer: D) -> Result<OffsetDateTime, D::Error>
143149
where
144150
D: Deserializer<'de>,
145151
{
146-
let seconds_from_now: i64 = Deserialize::deserialize(deserializer)?;
147-
Ok(OffsetDateTime::now_utc() + Duration::seconds(seconds_from_now))
152+
// For impersonating service accounts, the token endpoint returns
153+
// expiresTime: [iso8601] instead of the usual expires_in: [seconds]
154+
#[derive(Deserialize)]
155+
#[serde(untagged)]
156+
enum SecondsOrTime {
157+
Seconds(i64),
158+
Time(String),
159+
}
160+
161+
// First try to deserialize seconds
162+
let time_options: SecondsOrTime = Deserialize::deserialize(deserializer)?;
163+
164+
match time_options {
165+
SecondsOrTime::Seconds(seconds_from_now) => {
166+
Ok(OffsetDateTime::now_utc() + Duration::seconds(seconds_from_now))
167+
}
168+
SecondsOrTime::Time(time) => {
169+
OffsetDateTime::parse(time.as_str(), &Iso8601::PARSING).map_err(de::Error::custom)
170+
}
171+
}
148172
}
149173

150174
pub(crate) fn client() -> HyperClient {
@@ -192,4 +216,11 @@ mod tests {
192216
assert!(expires_at < expires + Duration::seconds(1));
193217
assert!(expires_at > expires - Duration::seconds(1));
194218
}
219+
220+
#[test]
221+
fn test_deserialize_real_life_camel_case() {
222+
let resp_body = "{\n \"accessToken\": \"secret_token\",\n \"expireTime\": \"2023-08-18T04:09:45Z\"\n}";
223+
let token: Token = serde_json::from_str(resp_body).expect("Failed to parse token");
224+
assert_eq!(token.as_str(), "secret_token");
225+
}
195226
}

0 commit comments

Comments
 (0)