Skip to content
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
2 changes: 1 addition & 1 deletion .cargo/mutants.toml
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ exclude_re = [
# `a_builder_without_a_transport_is_refused_when_there_is_no_bundled_one`.
'stack-auth/src/error\.rs:\d+:\d+: replace RequestError::is_no_transport -> bool with false$',
'stack-auth/src/auto_strategy\.rs:150:9: replace AutoStrategy::detect_inner -> Result<Self, AuthError> with Ok\(Default::default\(\)\)$',
'stack-auth/src/token_store\.rs:(261|269):9: replace <impl TokenStore for TokenStoreFn<L, S>>::(load|save)',
'stack-auth/src/token_store\.rs:(273|281):9: replace <impl TokenStore for TokenStoreFn<L, S>>::(load|save)',
# stack-auth — equivalent.
'stack-auth/src/(access_key|oidc)_refresher\.rs:\d+:\d+: replace <impl Refresher for \w+(<P>)?>::try_credential -> Option<Self::Credential> with Some\(Default::default\(\)\)$',
'stack-auth/src/error\.rs:\d+:\d+: replace AuthErrorKind::payload -> serde_json::Map<String, serde_json::Value> with Default::default\(\)$',
Expand Down
20 changes: 20 additions & 0 deletions .changeset/auth-authorise-expiry-cap-and-body-wiping.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@cipherstash/auth': patch
---

Cap the access-token expiry from CTS's `/api/authorise` at 30 days from now,
as a relative `expires_in` already is. A token loaded from a token store, or
passed in when the client is built, that claims to last longer than that is
refreshed before it is used. A bad `expiry` value can no longer make a token look
valid for ever, which kept the client sending a dead token until the process
restarted.

Token JSON saved by `InMemoryTokenStore`, callback token stores (including the
`loadToken`/`saveToken` callbacks of the Node and WASM builds), and the request
bodies sent to CTS (access keys, OIDC JWTs, refresh tokens), are now
serialised into a buffer that wipes itself as it grows, so no partial copies
are left in memory.

A response body from CTS, which carries the new access token, is now taken over
from the HTTP client's buffer where that buffer is not shared, rather than always
copied, so it is wiped with the response instead of being freed unwiped.
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions languages/golang/auth/guest/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions languages/golang/encrypt/guest/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 6 additions & 2 deletions languages/typescript/packages/auth/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -296,12 +296,16 @@ impl TokenStore for NapiTokenStore {
}

async fn save(&self, token: &Token) {
let Ok(json) = serde_json::to_string(token).map(Zeroizing::new) else {
// Serialise into a `SecretBuf`, which wipes every buffer it
// outgrows, then move the bytes into the `String` the JS callback
// takes.
let Ok(mut json) = stack_profile::SecretBuf::json_string(token) else {
return;
};
let json = std::mem::take(&mut *json);
let (tx, rx) = oneshot::channel::<Promise<()>>();
let status = self.save.call_with_return_value(
json.to_string(),
json,
ThreadsafeFunctionCallMode::NonBlocking,
move |promise: Promise<()>| {
let _ = tx.send(promise);
Expand Down
2 changes: 2 additions & 0 deletions languages/typescript/packages/stack-auth-wasm/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ crate-type = ["cdylib", "rlib"]

[dependencies]
stack-auth = { workspace = true, features = ["http"] }
# `SecretBuf`, to serialise saved tokens without leaving unwiped copies.
stack-profile = { workspace = true }
cts-common = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
Expand Down
4 changes: 3 additions & 1 deletion languages/typescript/packages/stack-auth-wasm/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,9 @@ impl TokenStore for JsTokenStore {
}

async fn save(&self, token: &Token) {
let Ok(json) = serde_json::to_string(token).map(Zeroizing::new) else {
// Serialise into a `SecretBuf`, which wipes every buffer it
// outgrows, and the result when it is dropped.
let Ok(json) = stack_profile::SecretBuf::json_string(token) else {
return;
};
let promise = match self.save.call1(&JsValue::NULL, &JsValue::from_str(&json)) {
Expand Down
3 changes: 3 additions & 0 deletions packages/stack-auth/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ serde_json = { workspace = true }
# `.form()` does (it uses this crate), so the bundled transport and a host's
# own transport send byte-identical requests.
serde_urlencoded = "0.7"
# `serde_urlencoded` only writes into a `form_urlencoded::Serializer`, which
# `transport::form_body` builds itself so it can size the target up front.
form_urlencoded = "1.2"
# `OidcFederationStrategy` keys its per-user token cache on the SHA-256 of
# the provider JWT, and stamps that digest on the tokens it persists.
sha2 = "0.10"
Expand Down
1 change: 1 addition & 0 deletions packages/stack-auth/fuzz/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

34 changes: 32 additions & 2 deletions packages/stack-auth/src/access_key_refresher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,9 @@ impl Refresher for AccessKeyRefresher {
let auth_resp: AuthoriseResponse = resp.json()?;

// The response → Token mapping (including the absolute-epoch `expiry`
// handling that CIP-3233 fixed) lives on `From<AuthoriseResponse>`.
Ok(auth_resp.into())
// handling that CIP-3233 fixed, and the 30-day cap) lives on
// `AuthoriseResponse::into_token`.
Ok(auth_resp.into_token(crate::token::now_unix_secs()))
}
}

Expand Down Expand Up @@ -209,6 +210,35 @@ mod tests {
);
}

/// A CTS that answers with an absurd absolute `expiry` (buggy or hostile)
/// must not mint a token that never expires: `AutoRefresh` would then keep
/// sending a dead token until the process restarted. The expiry is capped
/// at 30 days from now, the same cap a relative `expires_in` gets.
#[tokio::test]
async fn access_key_expiry_is_capped_at_thirty_days() {
let mut mocks = MockSet::new();
mocks.mock(|when, then| {
when.post().path("/api/authorise");
then.json(serde_json::json!({ "accessToken": "tok", "expiry": u64::MAX }));
});
let server = start_server(mocks).await;

let refresher = AccessKeyRefresher::new(
SecretToken::new("CSAKid.secret"),
server.url(""),
None,
default_transport(),
);
let token = refresher.refresh(&()).await.unwrap();

assert!(
token.expires_in() <= crate::token::MAX_TOKEN_LIFETIME_SECS,
"expiry must be capped at 30 days; got expires_in = {}",
token.expires_in()
);
assert!(!token.is_expired(), "a capped token is still a live token");
}

// ---- Initial auth tests ----

#[tokio::test]
Expand Down
59 changes: 47 additions & 12 deletions packages/stack-auth/src/authorize_dto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
//! the wire contract lives here in one place. The request bodies differ
//! (different credential fields) and stay private to each exchange.

use crate::token::cap_expires_at;
use crate::{SecretToken, Token};

/// Success response from `POST /api/authorise`.
Expand All @@ -17,24 +18,27 @@ pub(crate) struct AuthoriseResponse {
pub(crate) expiry: u64,
}

/// A `/api/authorise` success response *is* a complete CTS service token. Both
/// the access-key and OIDC federation flows hit the same endpoint and mint the
/// same kind of token, so the response → [`Token`] mapping lives here once
/// rather than being duplicated in each refresher — duplication is exactly how
/// the CIP-3233 expiry bug survived in the OIDC refresher after being fixed for
/// access keys.
impl From<AuthoriseResponse> for Token {
fn from(resp: AuthoriseResponse) -> Self {
impl AuthoriseResponse {
/// A `/api/authorise` success response *is* a complete CTS service token.
/// Both the access-key and OIDC federation flows hit the same endpoint and
/// mint the same kind of token, so the response → [`Token`] mapping lives
/// here once rather than being duplicated in each refresher — duplication
/// is exactly how the CIP-3233 expiry bug survived in the OIDC refresher
/// after being fixed for access keys.
///
/// `now` is the current Unix time, used only to cap the server's expiry.
pub(crate) fn into_token(self, now: u64) -> Token {
Token {
access_token: resp.access_token,
access_token: self.access_token,
token_type: "Bearer".to_string(),
// CTS `/api/authorise` returns `expiry` as an ABSOLUTE Unix epoch (it is
// the JWT `exp` claim), NOT a relative duration. Adding `now` here would
// push the local expiry decades into the future, so `AutoRefresh` would
// never consider the token expired and never refresh it — the token would
// then silently die at its real (~15 min) `exp` and every request would
// fail until the process restarted. Use the value as-is. See CIP-3233.
expires_at: resp.expiry,
// fail until the process restarted. Use the value as-is (see CIP-3233),
// capped at 30 days from now so an absurd value cannot do the same.
expires_at: cap_expires_at(now, self.expiry),
// `/api/authorise` issues no refresh token and carries no region / client
// / device metadata; those are populated only by the OAuth device flow.
refresh_token: None,
Expand All @@ -49,6 +53,7 @@ impl From<AuthoriseResponse> for Token {
#[cfg(test)]
mod tests {
use super::*;
use crate::token::MAX_TOKEN_LIFETIME_SECS;

/// The single source of truth for the response → `Token` mapping: CTS
/// `expiry` is an ABSOLUTE Unix epoch and must be used verbatim. Pre-fix the
Expand All @@ -61,7 +66,7 @@ mod tests {
expiry: 1_900_000_000, // an absolute Unix epoch, not a duration
};

let token = Token::from(resp);
let token = resp.into_token(1_899_999_100);

assert_eq!(
token.expires_at(),
Expand All @@ -75,4 +80,34 @@ mod tests {
"/api/authorise issues no refresh token"
);
}

/// An `expiry` more than 30 days after `now` is cut back to `now` plus 30
/// days, however large it is.
#[test]
fn authorise_response_caps_expiry_at_thirty_days_from_now() {
let now = 1_900_000_000;
for expiry in [now + MAX_TOKEN_LIFETIME_SECS + 1, u64::MAX] {
let resp = AuthoriseResponse {
access_token: SecretToken::new("cts-token"),
expiry,
};
assert_eq!(
resp.into_token(now).expires_at(),
now + MAX_TOKEN_LIFETIME_SECS
);
}
}

/// An `expiry` exactly 30 days out is kept, and so is one already past.
#[test]
fn authorise_response_keeps_an_expiry_within_the_cap() {
let now = 1_900_000_000;
for expiry in [now + MAX_TOKEN_LIFETIME_SECS, now - 1] {
let resp = AuthoriseResponse {
access_token: SecretToken::new("cts-token"),
expiry,
};
assert_eq!(resp.into_token(now).expires_at(), expiry);
}
}
}
Loading
Loading