Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

- Reject malformed supplied shared, control and worker static tokens during
client construction, including unused or masked options. Diagnostics name
only the option. Empty tokens and valid token bytes retain their precedence.

- Add `Client::get_run_activities` with typed run activity and attempt metadata,
preserving unavailable fields, diagnostic authority and heartbeat progress.

Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ readme = "README.md"
rust-version = "1.86"
keywords = ["durable", "workflow", "temporal", "worker", "sdk"]
categories = ["api-bindings", "asynchronous"]
include = ["/src/**", "/schema/**", "/examples/**", "/Cargo.toml", "/README.md", "/CHANGELOG.md", "/LICENSE", "/tests/fixtures/replay-regressions/published-php-complete-explicit-backoff.json", "/tests/fixtures/sdk-metrics-contract.json"]
include = ["/src/**", "/schema/**", "/examples/**", "/Cargo.toml", "/README.md", "/CHANGELOG.md", "/LICENSE", "/tests/fixtures/replay-regressions/published-php-complete-explicit-backoff.json", "/tests/fixtures/sdk-metrics-contract.json", "/tests/fixtures/auth-configuration/static-credentials-v1.json"]

[package.metadata.durable-workflow]
product-train = "4.12.0"
Expand Down
48 changes: 48 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,11 @@ pub enum Error {
"invalid Durable Workflow base URL: omit the SDK-owned /api suffix and pass the Server or Cloud runtime base URL; the SDK appends /api automatically"
)]
InvalidBaseUrl,
/// A supplied static credential failed validation before client construction.
#[error(
"invalid static credential option {option}: leading or trailing Unicode whitespace or control character"
)]
InvalidStaticCredential { option: &'static str },
#[error("invalid additional root certificates: {0}")]
InvalidRootCertificates(String),
#[error("invalid raw request: {0}")]
Expand Down Expand Up @@ -5934,6 +5939,39 @@ pub struct ClientBuilder {
danger_accept_invalid_certs: bool,
}

fn validate_static_credential(option: &'static str, value: Option<&str>) -> Result<()> {
let Some(value) = value else {
return Ok(());
};
// The cross-SDK policy fixes Unicode White_Space at the edges and Cc
// anywhere. Do not normalize, trim, or broaden the accepted token alphabet.
let edge_whitespace = |character| {
matches!(
character,
'\u{0009}'..='\u{000d}'
| '\u{0020}'
| '\u{0085}'
| '\u{00a0}'
| '\u{1680}'
| '\u{2000}'..='\u{200a}'
| '\u{2028}'
| '\u{2029}'
| '\u{202f}'
| '\u{205f}'
| '\u{3000}'
)
};
if value.chars().next().is_some_and(edge_whitespace)
|| value.chars().next_back().is_some_and(edge_whitespace)
|| value
.chars()
.any(|character| matches!(character, '\u{0000}'..='\u{001f}' | '\u{007f}'..='\u{009f}'))
{
return Err(Error::InvalidStaticCredential { option });
}
Ok(())
}

impl ClientBuilder {
/// Configure stable request metrics. Newly constructed workers inherit this recorder.
pub fn metrics_recorder(mut self, recorder: Arc<dyn metrics::MetricsRecorder>) -> Self {
Expand All @@ -5946,16 +5984,22 @@ impl ClientBuilder {
self
}

/// Set the shared static token. Build validates every supplied token without
/// trimming it. `None` is absent; `Some("")` remains a supplied empty token.
pub fn token(mut self, token: Option<String>) -> Self {
self.token = token;
self
}

/// Set the control-plane token, which takes precedence over the shared token
/// even when empty. Build also validates tokens for unused roles.
pub fn control_token(mut self, token: Option<String>) -> Self {
self.control_token = token;
self
}

/// Set the worker-plane token, which takes precedence over the shared token
/// even when empty. Build also validates tokens for unused roles.
pub fn worker_token(mut self, token: Option<String>) -> Self {
self.worker_token = token;
self
Expand All @@ -5979,6 +6023,9 @@ impl ClientBuilder {
}

pub fn build(self) -> Result<Client> {
validate_static_credential("token", self.token.as_deref())?;
validate_static_credential("control_token", self.control_token.as_deref())?;
validate_static_credential("worker_token", self.worker_token.as_deref())?;
let base_url = self.base_url.trim_end_matches('/').to_string();
let has_sdk_api_suffix = reqwest::Url::parse(&base_url)
.map(|url| url.path().trim_end_matches('/').ends_with("/api"))
Expand Down Expand Up @@ -19299,6 +19346,7 @@ mod tests {
mod nexus;
mod runtime_payloads;
mod runtime_uploads;
mod static_credentials;
mod version_marker_contract;
mod worker_capacity;
mod worker_session;
Expand Down
188 changes: 188 additions & 0 deletions src/tests/static_credentials.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
use super::*;
use sha2::{Digest, Sha256};

const FIXTURE: &[u8] =
include_bytes!("../../tests/fixtures/auth-configuration/static-credentials-v1.json");

fn fixture() -> Value {
serde_json::from_slice(FIXTURE).unwrap()
}

fn text(points: &Value) -> String {
points
.as_array()
.unwrap()
.iter()
.map(|point| char::from_u32(point.as_u64().unwrap().try_into().unwrap()).unwrap())
.collect()
}

fn configure(builder: ClientBuilder, option: &str, value: String) -> ClientBuilder {
match option {
"token" => builder.token(Some(value)),
"control_token" => builder.control_token(Some(value)),
"worker_token" => builder.worker_token(Some(value)),
_ => panic!("unknown fixture option"),
}
}

fn invalid(result: Result<Client>, option: &str) {
let error = match result {
Err(error) => error,
Ok(_) => panic!("malformed {option} was accepted"),
};
assert_eq!(
error.to_string(),
format!("invalid static credential option {option}: leading or trailing Unicode whitespace or control character")
);
assert_eq!(
format!("{error:?}"),
format!("InvalidStaticCredential {{ option: {option:?} }}")
);
}

#[test]
fn frozen_static_credential_fixture_identity_and_rust_mapping() {
assert_eq!(FIXTURE.len(), 60_716);
assert_eq!(
format!("{:x}", Sha256::digest(FIXTURE)),
"0d4f01b763d72850943acbd9a74a0cc5da1ca6db5bc1b6fa5ca37fda11810acb"
);
let fixture = fixture();
assert_eq!(
fixture["language_mappings"]["rust"],
json!({
"logical_setting_names": {"shared": "token", "control": "control_token", "worker": "worker_token"},
"none": "existing-absence",
"some_literal_empty": "preserve-supplied-empty-and-fallback-precedence",
"opposite_only": "existing-role-refusal"
})
);
}

#[test]
fn static_credential_common_scalar_vectors_validate_every_option() {
let fixture = fixture();
let rows = fixture["common_scalar_cases"].as_array().unwrap();
assert_eq!(rows.len(), 286);
for row in rows {
let value = text(&row["input_code_points"]);
for option in ["token", "control_token", "worker_token"] {
let result =
configure(Client::builder("http://127.0.0.1:1"), option, value.clone()).build();
match row["expected"].as_str().unwrap() {
"preserve" => {
let client = result.unwrap();
let protocol = if option == "worker_token" {
RequestProtocol::DefaultWorker
} else {
RequestProtocol::ControlPlane
};
let selected = client.auth_token(protocol).unwrap().unwrap();
let hex: String = selected.bytes().map(|byte| format!("{byte:02x}")).collect();
assert_eq!(hex, row["expected_utf8_hex"].as_str().unwrap());
assert_eq!(selected, value);
}
"reject" => invalid(result, option),
_ => panic!("unknown fixture verdict"),
}
}
}
}

#[test]
fn static_credential_unused_and_masked_options_refuse_before_selection() {
let fixture = fixture();
let rows = fixture["pre_selection_cases"].as_array().unwrap();
assert_eq!(rows.len(), 3);
let names = &fixture["language_mappings"]["rust"]["logical_setting_names"];
for row in rows {
let mut builder = Client::builder("http://127.0.0.1:1");
for (logical, points) in row["options_code_points"].as_object().unwrap() {
builder = configure(builder, names[logical].as_str().unwrap(), text(points));
}
let offending = row["offending_option"].as_str().unwrap();
invalid(builder.build(), names[offending].as_str().unwrap());
}
}

#[test]
fn static_credential_refusal_precedes_other_client_construction_errors() {
for option in ["token", "control_token", "worker_token"] {
invalid(
configure(
Client::builder("http://127.0.0.1:1/api"),
option,
"fixture-value\n".to_owned(),
)
.build(),
option,
);
}
}

#[test]
fn static_credential_absence_empty_and_role_precedence_stay_distinct() {
let anonymous = Client::builder("http://127.0.0.1:1").build().unwrap();
assert_eq!(
anonymous.auth_token(RequestProtocol::ControlPlane).unwrap(),
None
);
assert_eq!(
anonymous
.auth_token(RequestProtocol::DefaultWorker)
.unwrap(),
None
);
for shared in ["fixture-shared", ""] {
let client = Client::builder("http://127.0.0.1:1")
.token(Some(shared.into()))
.control_token(None)
.worker_token(None)
.build()
.unwrap();
assert_eq!(
client.auth_token(RequestProtocol::ControlPlane).unwrap(),
Some(shared)
);
assert_eq!(
client.auth_token(RequestProtocol::DefaultWorker).unwrap(),
Some(shared)
);
}
for scoped in ["fixture-scoped", ""] {
let client = Client::builder("http://127.0.0.1:1")
.token(Some("fixture-shared".into()))
.control_token(Some(scoped.into()))
.worker_token(Some(scoped.into()))
.build()
.unwrap();
assert_eq!(
client.auth_token(RequestProtocol::ControlPlane).unwrap(),
Some(scoped)
);
assert_eq!(
client.auth_token(RequestProtocol::DefaultWorker).unwrap(),
Some(scoped)
);
}
let control_only = Client::builder("http://127.0.0.1:1")
.control_token(Some("fixture-control".into()))
.build()
.unwrap();
assert!(matches!(
control_only.auth_token(RequestProtocol::DefaultWorker),
Err(Error::MissingRoleCredentials { role: "worker", .. })
));
let worker_only = Client::builder("http://127.0.0.1:1")
.worker_token(Some("fixture-worker".into()))
.build()
.unwrap();
assert!(matches!(
worker_only.auth_token(RequestProtocol::ControlPlane),
Err(Error::MissingRoleCredentials {
role: "control",
..
})
));
}
15 changes: 15 additions & 0 deletions tests/fixtures/auth-configuration/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Static credential configuration fixture

`static-credentials-v1.json` is copied byte for byte from
[`durable-workflow/server` at `23972b1d12fcaa64788480eda0a31de7a5347422`](https://github.com/durable-workflow/server/blob/23972b1d12fcaa64788480eda0a31de7a5347422/tests/Fixtures/AuthConfiguration/static-credentials-v1.json).
It has 60,716 bytes and SHA-256
`0d4f01b763d72850943acbd9a74a0cc5da1ca6db5bc1b6fa5ca37fda11810acb`.
Server issue [#554](https://github.com/durable-workflow/server/issues/554)
records the shared policy and exact-row review.

The Rust consumer applies the 286 common scalar rows to each typed token option
and the three pre-selection rows to client construction. It preserves the Rust
mapping for `None`, supplied empty strings and role selection. PHP byte-loader,
Server setting and decoded binary key rows are outside the Rust token surface.
All strings are synthetic. Passing these checks establishes constructor/text
behavior, without making an authentication or HTTP-header acceptance claim.
Loading
Loading