Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Supporting service account key format OR user credential formats #76

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 47 additions & 7 deletions src/authentication_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use crate::default_authorized_user::ConfigDefaultCredentials;
use crate::default_service_account::MetadataServiceAccount;
use crate::error::Error;
use crate::gcloud_authorized_user::GCloudAuthorizedUser;
use crate::types::{self, HyperClient, Token};
use crate::types::{self, CredentialSource, HyperClient, Token};

#[async_trait]
pub(crate) trait ServiceAccount: Send + Sync {
Expand Down Expand Up @@ -43,15 +43,55 @@ impl AuthenticationManager {
#[tracing::instrument]
pub async fn new() -> Result<Self, Error> {
tracing::debug!("Initializing gcp_auth");
if let Some(service_account) = CustomServiceAccount::from_env()? {
return Ok(service_account.into());
let client = types::client();
msdrigg marked this conversation as resolved.
Show resolved Hide resolved
if let Some(service_account_creds) = CredentialSource::from_env().await? {
tracing::debug!("Using GOOGLE_APPLICATION_CREDENTIALS env");

let service_account: Box<dyn ServiceAccount> = match service_account_creds {
CredentialSource::ServiceAccount(creds) => {
let service_account = CustomServiceAccount::new(creds)?;
Box::new(service_account)
}
CredentialSource::AuthorizedUser(creds) => {
let service_account =
ConfigDefaultCredentials::from_user_credentials(creds, &client).await?;
Box::new(service_account)
}
};

return Ok(Self {
service_account,
client,
refresh_mutex: Mutex::new(()),
});
}

let client = types::client();
let default_user_error = match ConfigDefaultCredentials::new(&client).await {
Ok(service_account) => {
let default_user_error = match CredentialSource::from_default_credentials().await {
Ok(service_account_creds) => {
tracing::debug!("Using ConfigDefaultCredentials");
return Ok(Self::build(client, service_account));

let service_account: Result<Box<dyn ServiceAccount>, Error> =
match service_account_creds {
CredentialSource::AuthorizedUser(creds) => {
ConfigDefaultCredentials::from_user_credentials(creds, &client)
.await
.map(|creds| Box::new(creds) as _)
msdrigg marked this conversation as resolved.
Show resolved Hide resolved
}
CredentialSource::ServiceAccount(creds) => {
CustomServiceAccount::new(creds).map(|creds| Box::new(creds) as _)
}
};

match service_account {
Ok(service_account) => {
return Ok(Self {
service_account,
client,
refresh_mutex: Mutex::new(()),
});
}
Err(e) => e,
}
}
Err(e) => e,
};
Expand Down
3 changes: 1 addition & 2 deletions src/custom_service_account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ impl CustomServiceAccount {
}
}

fn new(credentials: ApplicationCredentials) -> Result<Self, Error> {
pub(crate) fn new(credentials: ApplicationCredentials) -> Result<Self, Error> {
Ok(Self {
signer: Signer::new(&credentials.private_key)?,
credentials,
Expand Down Expand Up @@ -137,7 +137,6 @@ impl ServiceAccount for CustomServiceAccount {

#[derive(Serialize, Deserialize, Clone)]
pub(crate) struct ApplicationCredentials {
pub(crate) r#type: Option<String>,
/// project_id
pub(crate) project_id: Option<String>,
/// private_key_id
Expand Down
20 changes: 5 additions & 15 deletions src/default_authorized_user.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
use std::fs;
use std::sync::RwLock;

use async_trait::async_trait;
Expand All @@ -19,18 +18,11 @@ pub(crate) struct ConfigDefaultCredentials {

impl ConfigDefaultCredentials {
const DEFAULT_TOKEN_GCP_URI: &'static str = "https://accounts.google.com/o/oauth2/token";
const USER_CREDENTIALS_PATH: &'static str =
".config/gcloud/application_default_credentials.json";

pub(crate) async fn new(client: &HyperClient) -> Result<Self, Error> {
tracing::debug!("Loading user credentials file");
let mut home = dirs_next::home_dir().ok_or(Error::NoHomeDir)?;
home.push(Self::USER_CREDENTIALS_PATH);

let file = fs::File::open(home).map_err(Error::UserProfilePath)?;
let credentials = serde_json::from_reader::<_, UserCredentials>(file)
.map_err(Error::UserProfileFormat)?;

pub(crate) async fn from_user_credentials(
credentials: UserCredentials,
client: &HyperClient,
) -> Result<Self, Error> {
Ok(Self {
token: RwLock::new(Self::get_token(&credentials, client).await?),
credentials,
Expand Down Expand Up @@ -105,7 +97,7 @@ struct RefreshRequest<'a> {
}

#[derive(Serialize, Deserialize, Debug, Clone)]
struct UserCredentials {
pub(crate) struct UserCredentials {
/// Client id
pub(crate) client_id: String,
/// Client secret
Expand All @@ -114,8 +106,6 @@ struct UserCredentials {
pub(crate) quota_project_id: Option<String>,
/// Refresh Token
pub(crate) refresh_token: String,
/// Type
pub(crate) r#type: String,
}
djc marked this conversation as resolved.
Show resolved Hide resolved

/// How many times to attempt to fetch a token from the GCP token endpoint.
Expand Down
182 changes: 181 additions & 1 deletion src/types.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use std::io::BufReader;
use std::sync::Arc;
use std::{fmt, io};

Expand All @@ -11,7 +12,10 @@ use serde::Deserializer;
use serde::{Deserialize, Serialize};
use time::{Duration, OffsetDateTime};

use crate::Error;
use crate::{
custom_service_account::ApplicationCredentials, default_authorized_user::UserCredentials, Error,
};

/// Represents an access token. All access tokens are Bearer tokens.
///
/// Tokens should not be cached, the [`AuthenticationManager`] handles the correct caching
Expand Down Expand Up @@ -159,8 +163,57 @@ pub(crate) fn client() -> HyperClient {
pub(crate) type HyperClient =
hyper::Client<hyper_rustls::HttpsConnector<hyper::client::HttpConnector>>;

// Implementation referenced from
// https://github.com/golang/oauth2/blob/a835fc4358f6852f50c4c5c33fddcd1adade5b0a/google/google.go#L158
// Currently not implementing external account credentials
// Currently not implementing impersonating service accounts (coming soon !)
#[derive(Serialize, Deserialize, Debug)]
#[serde(tag = "type", rename_all = "snake_case")]
pub(crate) enum CredentialSource {
// This credential parses the `key.json` file created when running
// `gcloud iam service-accounts keys create key.json --iam-account=SA_NAME@PROJECT_ID.iam.gserviceaccount.com`
ServiceAccount(ApplicationCredentials),
// This credential parses the `~/.config/gcloud/application_default_credentials.json` file
// created when running `gcloud auth application-default login`
AuthorizedUser(UserCredentials),
}

impl CredentialSource {
const USER_CREDENTIALS_PATH: &'static str =
".config/gcloud/application_default_credentials.json";

pub(crate) async fn from_env() -> Result<Option<Self>, Error> {
let creds_path = std::env::var_os("GOOGLE_APPLICATION_CREDENTIALS");
let Some(path) = creds_path else {
return Ok(None);
};
tracing::debug!("Reading credentials file from GOOGLE_APPLICATION_CREDENTIALS env var");
let file = std::fs::File::open(path).map_err(Error::CustomServiceAccountPath)?;

serde_json::from_reader::<_, CredentialSource>(BufReader::new(file))
.map_err(Error::CustomServiceAccountCredentials)
.map(Some)
}

pub(crate) async fn from_default_credentials() -> Result<Self, Error> {
tracing::debug!("Loading user credentials file");
let mut home = dirs_next::home_dir().ok_or(Error::NoHomeDir)?;
home.push(Self::USER_CREDENTIALS_PATH);

let file = std::fs::File::open(home).map_err(Error::CustomServiceAccountPath)?;

serde_json::from_reader::<_, CredentialSource>(BufReader::new(file))
.map_err(Error::CustomServiceAccountCredentials)
}
}

#[cfg(test)]
mod tests {
use crate::{
authentication_manager::ServiceAccount, default_authorized_user::ConfigDefaultCredentials,
CustomServiceAccount,
};

use super::*;

#[test]
Expand Down Expand Up @@ -192,4 +245,131 @@ mod tests {
assert!(expires_at < expires + Duration::seconds(1));
assert!(expires_at > expires - Duration::seconds(1));
}

#[tokio::test]
async fn test_parse_application_default_credentials() {
let test_creds = r#"{
"client_id": "***id***.apps.googleusercontent.com",
"client_secret": "***secret***",
"quota_project_id": "test_project",
"refresh_token": "***refresh***",
"type": "authorized_user"
}"#;

let cred_source: CredentialSource =
serde_json::from_str(test_creds).expect("Valid creds to parse");

assert!(matches!(cred_source, CredentialSource::AuthorizedUser(_)));

// Can't test converting this into a service account because it requires actually getting a key
}

#[tokio::test]
async fn test_parse_service_account_key() {
// Don't worry, even though the key is a real private_key, it's not used for anything
let test_creds = r#" {
"type": "service_account",
"project_id": "test_project",
"private_key_id": "***key_id***",
"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",
"client_email": "test_account@test.iam.gserviceaccount.com",
"client_id": "***id***",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/test_account%40test.iam.gserviceaccount.com",
"universe_domain": "googleapis.com"
}"#;

let cred_source: CredentialSource =
serde_json::from_str(test_creds).expect("Valid creds to parse");

assert!(matches!(cred_source, CredentialSource::ServiceAccount(_)));

let client = client();

let creds: Box<dyn ServiceAccount> = match cred_source {
CredentialSource::ServiceAccount(creds) => {
let service_account =
CustomServiceAccount::new(creds).expect("Valid creds to parse");

Box::new(service_account)
}
CredentialSource::AuthorizedUser(creds) => {
let service_account =
ConfigDefaultCredentials::from_user_credentials(creds, &client)
.await
.expect("Valid creds to parse");
Box::new(service_account)
}
};

assert_eq!(
creds
.project_id(&client)
.await
.expect("Project ID to be present"),
"test_project".to_string(),
"Project ID should be parsed"
);
}

#[tokio::test]
async fn test_additional_service_account_keys() {
// Using test cases from https://github.com/golang/oauth2/blob/a835fc4358f6852f50c4c5c33fddcd1adade5b0a/google/google_test.go#L40
// We have to use a real private key because we validate private keys on parsing as well.
let k1 = r#"{
"private_key_id": "268f54e43a1af97cfc71731688434f45aca15c8b",
"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",
"client_email": "gopher@developer.gserviceaccount.com",
"client_id": "gopher.apps.googleusercontent.com",
"token_uri": "https://accounts.google.com/o/gophers/token",
"type": "service_account",
"audience": "https://testservice.googleapis.com/"
}"#;

let k3 = r#"{
"private_key_id": "268f54e43a1af97cfc71731688434f45aca15c8b",
"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",
"client_email": "gopher@developer.gserviceaccount.com",
"client_id": "gopher.apps.googleusercontent.com",
"token_uri": "https://accounts.google.com/o/gophers/token",
"type": "service_account"
}"#;

let client = client();
for key in [k1, k3] {
let cred_source: CredentialSource =
serde_json::from_str(key).expect("Valid creds to parse");

assert!(matches!(cred_source, CredentialSource::ServiceAccount(_)));

let creds: Box<dyn ServiceAccount> = match cred_source {
CredentialSource::ServiceAccount(creds) => {
let service_account =
CustomServiceAccount::new(creds).expect("Valid creds to parse");

Box::new(service_account)
}
CredentialSource::AuthorizedUser(creds) => {
let service_account =
ConfigDefaultCredentials::from_user_credentials(creds, &client)
.await
.expect("Valid creds to parse");
Box::new(service_account)
}
};

assert!(
matches!(
creds
.project_id(&client)
.await
.expect_err("Project ID to not be present"),
crate::Error::ProjectIdNotFound,
),
"Project id should not be found here",
);
}
}
}
Loading