This is an automated email from the ASF dual-hosted git repository.
xuanwo pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/opendal-reqsign.git
The following commit(s) were added to refs/heads/main by this push:
new 08aa73e feat(google): Add workload identity support (#673)
08aa73e is described below
commit 08aa73eaf75ab0fa8fe1a39eaa5053ef66c0e713
Author: Xuanwo <[email protected]>
AuthorDate: Fri Dec 26 20:59:52 2025 +0800
feat(google): Add workload identity support (#673)
This PR will add workload identity support
---
**Parts of this PR were drafted with assistance from Codex (with
`gpt-5.2`) and fully reviewed and edited by me. I take full
responsibility for all changes.**
---
services/google/Cargo.toml | 1 +
.../src/provide_credential/external_account.rs | 409 +++++++++++++++++++--
.../google/testdata/test_external_account.json | 6 +-
services/google/tests/README.md | 12 +-
services/google/tests/mocks/sts_mock_server.py | 140 +++++++
5 files changed, 524 insertions(+), 44 deletions(-)
diff --git a/services/google/Cargo.toml b/services/google/Cargo.toml
index 76e1db8..6015ba3 100644
--- a/services/google/Cargo.toml
+++ b/services/google/Cargo.toml
@@ -28,6 +28,7 @@ rust-version.workspace = true
[dependencies]
async-trait = { workspace = true }
+form_urlencoded = { workspace = true }
http = { workspace = true }
jsonwebtoken = "9.2"
log = { workspace = true }
diff --git a/services/google/src/provide_credential/external_account.rs
b/services/google/src/provide_credential/external_account.rs
index c28cca7..91adcd5 100644
--- a/services/google/src/provide_credential/external_account.rs
+++ b/services/google/src/provide_credential/external_account.rs
@@ -17,6 +17,7 @@
use std::time::Duration;
+use form_urlencoded::Serializer;
use http::header::{ACCEPT, CONTENT_TYPE};
use log::{debug, error};
use serde::{Deserialize, Serialize};
@@ -43,18 +44,6 @@ struct ImpersonatedTokenResponse {
expire_time: String,
}
-/// STS token exchange request.
-#[derive(Serialize)]
-#[serde(rename_all = "camelCase")]
-struct StsTokenRequest {
- grant_type: &'static str,
- requested_token_type: &'static str,
- audience: String,
- scope: &'static str,
- subject_token: String,
- subject_token_type: String,
-}
-
/// Impersonation request.
#[derive(Serialize)]
struct ImpersonationRequest {
@@ -84,6 +73,13 @@ impl ExternalAccountCredentialProvider {
self
}
+ fn resolve_scope(&self, ctx: &Context) -> String {
+ self.scope
+ .clone()
+ .or_else(|| ctx.env_var(crate::constants::GOOGLE_SCOPE))
+ .unwrap_or_else(|| crate::constants::DEFAULT_SCOPE.to_string())
+ }
+
async fn load_oidc_token(&self, ctx: &Context) -> Result<String> {
match &self.external_account.credential_source {
external_account::Source::File(source) => {
@@ -98,9 +94,19 @@ impl ExternalAccountCredentialProvider {
ctx: &Context,
source: &external_account::FileSource,
) -> Result<String> {
- debug!("loading OIDC token from file: {}", source.file);
- let content = ctx.file_read(&source.file).await?;
- source.format.parse(&content)
+ let file = resolve_template(ctx, &source.file)?;
+ debug!("loading OIDC token from file: {}", file);
+
+ let content = ctx.file_read(&file).await?;
+ let token = source.format.parse(&content)?;
+ let token = token.trim().to_string();
+ if token.is_empty() {
+ return Err(reqsign_core::Error::credential_invalid(
+ "OIDC token loaded from file is empty",
+ ));
+ }
+
+ Ok(token)
}
async fn load_url_sourced_token(
@@ -108,13 +114,15 @@ impl ExternalAccountCredentialProvider {
ctx: &Context,
source: &external_account::UrlSource,
) -> Result<String> {
- debug!("loading OIDC token from URL: {}", source.url);
+ let url = resolve_template(ctx, &source.url)?;
+ debug!("loading OIDC token from URL: {}", url);
- let mut req = http::Request::get(&source.url);
+ let mut req = http::Request::get(&url);
// Add custom headers if any
if let Some(headers) = &source.headers {
for (key, value) in headers {
+ let value = resolve_template(ctx, value)?;
req = req.header(key, value);
}
}
@@ -133,31 +141,46 @@ impl ExternalAccountCredentialProvider {
)));
}
- source.format.parse(resp.body())
+ let token = source.format.parse(resp.body())?;
+ let token = token.trim().to_string();
+ if token.is_empty() {
+ return Err(reqsign_core::Error::credential_invalid(
+ "OIDC token loaded from URL is empty",
+ ));
+ }
+
+ Ok(token)
}
async fn exchange_sts_token(&self, ctx: &Context, oidc_token: &str) ->
Result<Token> {
debug!("exchanging OIDC token for STS access token");
- let request = StsTokenRequest {
- grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
- requested_token_type:
"urn:ietf:params:oauth:token-type:access_token",
- audience: self.external_account.audience.clone(),
- scope: "https://www.googleapis.com/auth/cloud-platform",
- subject_token: oidc_token.to_string(),
- subject_token_type:
self.external_account.subject_token_type.clone(),
- };
-
- let body = serde_json::to_vec(&request).map_err(|e| {
- reqsign_core::Error::unexpected("failed to serialize
request").with_source(e)
- })?;
+ let scope = self.resolve_scope(ctx);
+ let token_url = resolve_template(ctx,
&self.external_account.token_url)?;
+ let audience = resolve_template(ctx, &self.external_account.audience)?;
+ let subject_token_type = resolve_template(ctx,
&self.external_account.subject_token_type)?;
+
+ let body = Serializer::new(String::new())
+ .append_pair(
+ "grant_type",
+ "urn:ietf:params:oauth:grant-type:token-exchange",
+ )
+ .append_pair(
+ "requested_token_type",
+ "urn:ietf:params:oauth:token-type:access_token",
+ )
+ .append_pair("audience", &audience)
+ .append_pair("scope", &scope)
+ .append_pair("subject_token", oidc_token)
+ .append_pair("subject_token_type", &subject_token_type)
+ .finish();
let req = http::Request::builder()
.method(http::Method::POST)
- .uri(&self.external_account.token_url)
+ .uri(token_url)
.header(ACCEPT, "application/json")
- .header(CONTENT_TYPE, "application/json")
- .body(body.into())
+ .header(CONTENT_TYPE, "application/x-www-form-urlencoded")
+ .body(body.into_bytes().into())
.map_err(|e| {
reqsign_core::Error::unexpected("failed to build HTTP
request").with_source(e)
})?;
@@ -197,12 +220,7 @@ impl ExternalAccountCredentialProvider {
debug!("impersonating service account");
- let scope = self
- .scope
- .clone()
- .or_else(|| ctx.env_var(crate::constants::GOOGLE_SCOPE))
- .unwrap_or_else(|| crate::constants::DEFAULT_SCOPE.to_string());
-
+ let scope = self.resolve_scope(ctx);
let lifetime = self
.external_account
.service_account_impersonation
@@ -210,6 +228,14 @@ impl ExternalAccountCredentialProvider {
.and_then(|s| s.token_lifetime_seconds)
.unwrap_or(MAX_LIFETIME.as_secs() as usize);
+ let lifetime = if lifetime == 0 {
+ return Err(reqsign_core::Error::config_invalid(
+ "service_account_impersonation.token_lifetime_seconds must be
positive",
+ ));
+ } else {
+ lifetime.min(MAX_LIFETIME.as_secs() as usize)
+ };
+
let request = ImpersonationRequest {
scope: vec![scope.clone()],
lifetime: format!("{lifetime}s"),
@@ -224,7 +250,15 @@ impl ExternalAccountCredentialProvider {
.uri(url)
.header(ACCEPT, "application/json")
.header(CONTENT_TYPE, "application/json")
- .header("Authorization", format!("Bearer {access_token}"))
+ .header(http::header::AUTHORIZATION, {
+ let mut value: http::HeaderValue =
+ format!("Bearer {access_token}").parse().map_err(|e| {
+ reqsign_core::Error::unexpected("failed to parse
header value")
+ .with_source(e)
+ })?;
+ value.set_sensitive(true);
+ value
+ })
.body(body.into())
.map_err(|e| {
reqsign_core::Error::unexpected("failed to build HTTP
request").with_source(e)
@@ -278,3 +312,298 @@ impl ProvideCredential for
ExternalAccountCredentialProvider {
Ok(Some(Credential::with_token(final_token)))
}
}
+
+fn resolve_template(ctx: &Context, input: &str) -> Result<String> {
+ // Google external account credentials commonly contain `${VAR}`
placeholders that must be
+ // substituted using process environment variables (e.g. GitHub Actions
OIDC).
+ let mut out = String::with_capacity(input.len());
+ let mut rest = input;
+
+ loop {
+ let Some(start) = rest.find("${") else {
+ out.push_str(rest);
+ return Ok(out);
+ };
+
+ out.push_str(&rest[..start]);
+ rest = &rest[start + 2..];
+
+ let Some(end) = rest.find('}') else {
+ return Err(reqsign_core::Error::config_invalid(format!(
+ "invalid template syntax in value: {input}"
+ )));
+ };
+
+ let var = &rest[..end];
+ rest = &rest[end + 1..];
+
+ if var.is_empty() {
+ return Err(reqsign_core::Error::config_invalid(format!(
+ "empty template variable in value: {input}"
+ )));
+ }
+
+ let value = ctx.env_var(var).filter(|v| !v.is_empty()).ok_or_else(|| {
+ reqsign_core::Error::config_invalid(format!(
+ "missing environment variable {var} required by template:
{input}"
+ ))
+ })?;
+ out.push_str(&value);
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ use async_trait::async_trait;
+ use bytes::Bytes;
+ use http::header::{AUTHORIZATION, CONTENT_TYPE};
+ use reqsign_core::{Env, FileRead, HttpSend};
+ use std::collections::HashMap;
+ use std::path::PathBuf;
+
+ #[derive(Debug, Default)]
+ struct MockEnv {
+ vars: HashMap<String, String>,
+ }
+
+ impl MockEnv {
+ fn with_var(mut self, k: &str, v: &str) -> Self {
+ self.vars.insert(k.to_string(), v.to_string());
+ self
+ }
+ }
+
+ impl Env for MockEnv {
+ fn var(&self, key: &str) -> Option<String> {
+ self.vars.get(key).cloned()
+ }
+
+ fn vars(&self) -> HashMap<String, String> {
+ self.vars.clone()
+ }
+
+ fn home_dir(&self) -> Option<PathBuf> {
+ None
+ }
+ }
+
+ #[derive(Debug, Default)]
+ struct MockFileRead {
+ files: HashMap<String, Vec<u8>>,
+ }
+
+ impl MockFileRead {
+ fn with_file(mut self, path: &str, content: impl Into<Vec<u8>>) ->
Self {
+ self.files.insert(path.to_string(), content.into());
+ self
+ }
+ }
+
+ #[async_trait]
+ impl FileRead for MockFileRead {
+ async fn file_read(&self, path: &str) -> Result<Vec<u8>> {
+ self.files.get(path).cloned().ok_or_else(|| {
+ reqsign_core::Error::config_invalid(format!("file not found:
{path}"))
+ })
+ }
+ }
+
+ #[derive(Debug)]
+ struct CaptureStsHttpSend {
+ expected_url: String,
+ expected_scope: String,
+ expected_subject_token: String,
+ expected_audience: String,
+ expected_subject_token_type: String,
+ access_token: String,
+ }
+
+ #[async_trait]
+ impl HttpSend for CaptureStsHttpSend {
+ async fn http_send(&self, req: http::Request<Bytes>) ->
Result<http::Response<Bytes>> {
+ assert_eq!(req.method(), http::Method::POST);
+ assert_eq!(req.uri().to_string(), self.expected_url);
+ assert_eq!(
+ req.headers()
+ .get(CONTENT_TYPE)
+ .expect("content-type must exist")
+ .to_str()
+ .expect("content-type must be valid string"),
+ "application/x-www-form-urlencoded"
+ );
+
+ let pairs: HashMap<String, String> =
form_urlencoded::parse(req.body().as_ref())
+ .into_owned()
+ .collect();
+ assert_eq!(
+ pairs.get("grant_type").map(String::as_str),
+ Some("urn:ietf:params:oauth:grant-type:token-exchange")
+ );
+ assert_eq!(
+ pairs.get("requested_token_type").map(String::as_str),
+ Some("urn:ietf:params:oauth:token-type:access_token")
+ );
+ assert_eq!(
+ pairs.get("audience").map(String::as_str),
+ Some(self.expected_audience.as_str())
+ );
+ assert_eq!(
+ pairs.get("scope").map(String::as_str),
+ Some(self.expected_scope.as_str())
+ );
+ assert_eq!(
+ pairs.get("subject_token").map(String::as_str),
+ Some(self.expected_subject_token.as_str())
+ );
+ assert_eq!(
+ pairs.get("subject_token_type").map(String::as_str),
+ Some(self.expected_subject_token_type.as_str())
+ );
+
+ let body = serde_json::json!({
+ "access_token": &self.access_token,
+ "expires_in": 3600
+ });
+ Ok(http::Response::builder()
+ .status(http::StatusCode::OK)
+ .body(serde_json::to_vec(&body).expect("json must
encode").into())
+ .expect("response must build"))
+ }
+ }
+
+ #[derive(Debug)]
+ struct UrlThenStsHttpSend {
+ expected_get_url: String,
+ expected_get_auth: String,
+ expected_post_url: String,
+ expected_subject_token: String,
+ }
+
+ #[async_trait]
+ impl HttpSend for UrlThenStsHttpSend {
+ async fn http_send(&self, req: http::Request<Bytes>) ->
Result<http::Response<Bytes>> {
+ match *req.method() {
+ http::Method::GET => {
+ assert_eq!(req.uri().to_string(), self.expected_get_url);
+ assert_eq!(
+ req.headers()
+ .get(AUTHORIZATION)
+ .expect("authorization must exist")
+ .to_str()
+ .expect("authorization must be valid string"),
+ self.expected_get_auth
+ );
+ Ok(http::Response::builder()
+ .status(http::StatusCode::OK)
+ .body(b"test-oidc-token".as_slice().into())
+ .expect("response must build"))
+ }
+ http::Method::POST => {
+ assert_eq!(req.uri().to_string(), self.expected_post_url);
+ let pairs: HashMap<String, String> =
+ form_urlencoded::parse(req.body().as_ref())
+ .into_owned()
+ .collect();
+ assert_eq!(
+ pairs.get("subject_token").map(String::as_str),
+ Some(self.expected_subject_token.as_str())
+ );
+ Ok(http::Response::builder()
+ .status(http::StatusCode::OK)
+ .body(
+
br#"{"access_token":"final-token","expires_in":3600}"#
+ .as_slice()
+ .into(),
+ )
+ .expect("response must build"))
+ }
+ _ => unreachable!("unexpected method"),
+ }
+ }
+ }
+
+ #[test]
+ fn test_resolve_template() {
+ let ctx = Context::new().with_env(MockEnv::default().with_var("FOO",
"bar"));
+ assert_eq!(resolve_template(&ctx, "a${FOO}c").unwrap(), "abarc");
+ }
+
+ #[tokio::test]
+ async fn test_external_account_file_source_uses_form_encoded_sts() ->
Result<()> {
+ let external_account = ExternalAccount {
+ audience: "aud".to_string(),
+ subject_token_type:
"urn:ietf:params:oauth:token-type:jwt".to_string(),
+ token_url: "https://sts.googleapis.com/v1/token".to_string(),
+ credential_source:
external_account::Source::File(external_account::FileSource {
+ file: "/var/run/token".to_string(),
+ format: external_account::Format::Text,
+ }),
+ service_account_impersonation_url: None,
+ service_account_impersonation: None,
+ };
+
+ let http = CaptureStsHttpSend {
+ expected_url: "https://sts.googleapis.com/v1/token".to_string(),
+ expected_scope: "scope-a".to_string(),
+ expected_subject_token: "test-oidc".to_string(),
+ expected_audience: "aud".to_string(),
+ expected_subject_token_type:
"urn:ietf:params:oauth:token-type:jwt".to_string(),
+ access_token: "access-token".to_string(),
+ };
+ let fs = MockFileRead::default().with_file("/var/run/token", b"
test-oidc \n");
+ let ctx = Context::new().with_http_send(http).with_file_read(fs);
+
+ let provider =
+
ExternalAccountCredentialProvider::new(external_account).with_scope("scope-a");
+ let cred = provider
+ .provide_credential(&ctx)
+ .await?
+ .expect("credential must exist");
+ assert!(cred.has_token());
+ assert!(cred.has_valid_token());
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn test_external_account_url_source_supports_env_templates() ->
Result<()> {
+ let external_account = ExternalAccount {
+ audience: "aud".to_string(),
+ subject_token_type:
"urn:ietf:params:oauth:token-type:jwt".to_string(),
+ token_url: "https://sts.googleapis.com/v1/token".to_string(),
+ credential_source:
external_account::Source::Url(external_account::UrlSource {
+ url: "https://example.com/${PATH}".to_string(),
+ format: external_account::Format::Text,
+ headers: Some(HashMap::from([(
+ "Authorization".to_string(),
+ "Bearer ${TOKEN}".to_string(),
+ )])),
+ }),
+ service_account_impersonation_url: None,
+ service_account_impersonation: None,
+ };
+
+ let http = UrlThenStsHttpSend {
+ expected_get_url: "https://example.com/oidc".to_string(),
+ expected_get_auth: "Bearer secret".to_string(),
+ expected_post_url:
"https://sts.googleapis.com/v1/token".to_string(),
+ expected_subject_token: "test-oidc-token".to_string(),
+ };
+
+ let env = MockEnv::default()
+ .with_var("PATH", "oidc")
+ .with_var("TOKEN", "secret");
+
+ let ctx = Context::new().with_http_send(http).with_env(env);
+
+ let provider =
ExternalAccountCredentialProvider::new(external_account);
+ let cred = provider
+ .provide_credential(&ctx)
+ .await?
+ .expect("credential must exist");
+ assert!(cred.has_token());
+ assert!(cred.has_valid_token());
+ Ok(())
+ }
+}
diff --git a/services/google/testdata/test_external_account.json
b/services/google/testdata/test_external_account.json
index 5c47b70..119c949 100644
--- a/services/google/testdata/test_external_account.json
+++ b/services/google/testdata/test_external_account.json
@@ -2,10 +2,10 @@
"type": "external_account",
"audience":
"//iam.googleapis.com/projects/000000000000/locations/global/workloadIdentityPools/reqsign/providers/reqsign-provider",
"subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
- "service_account_impersonation_url":
"https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/[email protected]:generateAccessToken",
- "token_url": "https://sts.googleapis.com/v1/token",
+ "service_account_impersonation_url":
"http://127.0.0.1:5000/v1/projects/-/serviceAccounts/[email protected]:generateAccessToken",
+ "token_url": "http://127.0.0.1:5000/v1/token",
"credential_source": {
- "url": "http://localhost:5000/token",
+ "url": "http://127.0.0.1:5000/token",
"format": {
"type": "json",
"subject_token_field_name": "id_token"
diff --git a/services/google/tests/README.md b/services/google/tests/README.md
index 614665e..bcabf08 100644
--- a/services/google/tests/README.md
+++ b/services/google/tests/README.md
@@ -112,4 +112,14 @@ The DefaultCredentialProvider automatically detects and
handles all these types.
- Impersonation tests require proper IAM permissions for the source credentials
- Tests use real GCS API endpoints to verify signature validity
- All tests are designed to be idempotent and safe to run repeatedly
-- Some credential provider tests use test data that will fail token exchange -
this is expected
\ No newline at end of file
+- Some credential provider tests use test data that will fail token exchange -
this is expected
+
+## Local STS Mock
+
+For local development without real Google Cloud credentials, you can run a
simple STS mock:
+
+```bash
+python3 services/google/tests/mocks/sts_mock_server.py 5000
+```
+
+The example credential file
`services/google/testdata/test_external_account.json` is configured to use this
mock.
diff --git a/services/google/tests/mocks/sts_mock_server.py
b/services/google/tests/mocks/sts_mock_server.py
new file mode 100644
index 0000000..a54e7b3
--- /dev/null
+++ b/services/google/tests/mocks/sts_mock_server.py
@@ -0,0 +1,140 @@
+#!/usr/bin/env python3
+"""
+Mock server for Google STS and external account credential sources.
+
+This server provides:
+- GET /token : returns an OIDC subject token in JSON format
+- POST /v1/token : accepts RFC 8693 token exchange via
application/x-www-form-urlencoded
+- POST */:generateAccessToken : simulates IAMCredentials generateAccessToken
(optional)
+
+It is intended for local testing with
services/google/testdata/test_external_account.json.
+"""
+
+import json
+import sys
+import time
+from datetime import datetime, timedelta, timezone
+from http.server import BaseHTTPRequestHandler, HTTPServer
+from urllib.parse import parse_qs
+
+
+def _read_body(handler: BaseHTTPRequestHandler) -> bytes:
+ length = int(handler.headers.get("Content-Length", "0"))
+ if length <= 0:
+ return b""
+ return handler.rfile.read(length)
+
+
+class StsHandler(BaseHTTPRequestHandler):
+ def do_GET(self):
+ if self.path == "/token":
+ self._handle_subject_token()
+ return
+
+ self.send_error(404, "Not Found")
+
+ def do_POST(self):
+ if self.path == "/v1/token":
+ self._handle_sts_token_exchange()
+ return
+
+ if self.path.endswith(":generateAccessToken"):
+ self._handle_generate_access_token()
+ return
+
+ self.send_error(404, "Not Found")
+
+ def _handle_subject_token(self):
+ token_response = {
+ "id_token": "mock-oidc-subject-token",
+ }
+ self.send_response(200)
+ self.send_header("Content-Type", "application/json")
+ self.end_headers()
+ self.wfile.write(json.dumps(token_response).encode())
+
+ def _handle_sts_token_exchange(self):
+ content_type = self.headers.get("Content-Type", "")
+ if not content_type.startswith("application/x-www-form-urlencoded"):
+ self.send_error(
+ 415,
+ "Content-Type must be application/x-www-form-urlencoded for
STS token exchange",
+ )
+ return
+
+ body = _read_body(self).decode("utf-8", errors="replace")
+ values = {k: v[0] for k, v in parse_qs(body,
keep_blank_values=True).items()}
+
+ required = [
+ "grant_type",
+ "requested_token_type",
+ "audience",
+ "scope",
+ "subject_token",
+ "subject_token_type",
+ ]
+ missing = [k for k in required if not values.get(k)]
+ if missing:
+ self.send_error(400, f"Missing required form fields: {',
'.join(missing)}")
+ return
+
+ if values["grant_type"] !=
"urn:ietf:params:oauth:grant-type:token-exchange":
+ self.send_error(400, "Invalid grant_type")
+ return
+ if values["requested_token_type"] !=
"urn:ietf:params:oauth:token-type:access_token":
+ self.send_error(400, "Invalid requested_token_type")
+ return
+
+ token_response = {
+ "access_token": f"mock-sts-access-token-{int(time.time())}",
+ "expires_in": 3600,
+ "token_type": "Bearer",
+ }
+ self.send_response(200)
+ self.send_header("Content-Type", "application/json")
+ self.end_headers()
+ self.wfile.write(json.dumps(token_response).encode())
+
+ def _handle_generate_access_token(self):
+ auth = self.headers.get("Authorization", "")
+ if not auth.startswith("Bearer "):
+ self.send_error(401, "Authorization: Bearer <token> required")
+ return
+
+ expires_in = 3600
+ expire_time = datetime.now(timezone.utc) +
timedelta(seconds=expires_in)
+ token_response = {
+ "accessToken":
f"mock-impersonated-access-token-{int(time.time())}",
+ "expireTime": expire_time.isoformat().replace("+00:00", "Z"),
+ }
+ self.send_response(200)
+ self.send_header("Content-Type", "application/json")
+ self.end_headers()
+ self.wfile.write(json.dumps(token_response).encode())
+
+ def log_message(self, format, *args):
+ sys.stderr.write(
+ f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {format %
args}\n"
+ )
+
+
+def run_server(port: int = 5000):
+ server_address = ("127.0.0.1", port)
+ httpd = HTTPServer(server_address, StsHandler)
+ print(f"Mock Google STS Server running on http://127.0.0.1:{port}")
+ print("Press Ctrl+C to stop")
+ print("")
+ print("Endpoints:")
+ print(f" GET http://127.0.0.1:{port}/token")
+ print(f" POST http://127.0.0.1:{port}/v1/token")
+ print(
+ f" POST
http://127.0.0.1:{port}/v1/projects/-/serviceAccounts/[email protected]:generateAccessToken"
+ )
+ httpd.serve_forever()
+
+
+if __name__ == "__main__":
+ port = 5000
+ if len(sys.argv) > 1:
+ port = int(sys.argv[1])
+ run_server(port)