use cts_common::{Crn, CtsServiceDiscovery, ServiceDiscovery, WorkspaceId};
use crate::auto_refresh::AutoRefresh;
use crate::oidc_refresher::{OidcProvider, OidcRefresher};
use crate::token_store::{NoStore, TokenStore};
use crate::{ensure_trailing_slash, AuthError, AuthStrategy, ServiceToken};
pub struct OidcFederationStrategy<P, S = NoStore> {
inner: AutoRefresh<OidcRefresher<P>, S>,
expected_workspace: WorkspaceId,
}
impl<P: OidcProvider> OidcFederationStrategy<P> {
pub fn new(workspace_crn: Crn, oidc_provider: P) -> Result<Self, AuthError> {
Self::builder(workspace_crn, oidc_provider).build()
}
pub fn builder(workspace_crn: Crn, oidc_provider: P) -> OidcFederationStrategyBuilder<P> {
OidcFederationStrategyBuilder {
workspace_crn,
oidc_provider,
base_url_override: None,
token_store: NoStore,
}
}
}
impl<P: OidcProvider, S: TokenStore> AuthStrategy for &OidcFederationStrategy<P, S> {
async fn get_token(self) -> Result<ServiceToken, AuthError> {
self.inner
.get_token()
.await?
.verify_workspace(self.expected_workspace)
}
}
pub struct OidcFederationStrategyBuilder<P, S = NoStore> {
workspace_crn: Crn,
oidc_provider: P,
base_url_override: Option<url::Url>,
token_store: S,
}
impl<P, S> OidcFederationStrategyBuilder<P, S> {
pub fn base_url(mut self, url: url::Url) -> Self {
self.base_url_override = Some(url);
self
}
pub fn maybe_base_url(self, base_url: Option<String>) -> Result<Self, AuthError> {
match base_url {
Some(s) if !s.is_empty() => Ok(self.base_url(s.parse::<url::Url>()?)),
_ => Ok(self),
}
}
pub fn with_token_store<T: TokenStore>(self, store: T) -> OidcFederationStrategyBuilder<P, T> {
OidcFederationStrategyBuilder {
workspace_crn: self.workspace_crn,
oidc_provider: self.oidc_provider,
base_url_override: self.base_url_override,
token_store: store,
}
}
}
impl<P: OidcProvider, S: TokenStore> OidcFederationStrategyBuilder<P, S> {
pub fn build(self) -> Result<OidcFederationStrategy<P, S>, AuthError> {
let expected_workspace = self.workspace_crn.workspace_id;
let region = self.workspace_crn.region;
let base_url = match self.base_url_override {
Some(url) => url,
None => {
crate::cts_base_url_from_env()?.unwrap_or(CtsServiceDiscovery::endpoint(region)?)
}
};
let refresher = OidcRefresher::new(
self.oidc_provider,
expected_workspace,
ensure_trailing_slash(base_url),
);
Ok(OidcFederationStrategy {
inner: AutoRefresh::with_store(refresher, self.token_store),
expected_workspace,
})
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use mocktail::prelude::*;
use super::*;
use crate::oidc_refresher::OidcProviderFn;
use crate::test_support::{crn_with_workspace, jwt_with_workspace};
use crate::{InMemoryTokenStore, SecretToken, Token, TokenStore};
async fn start_mock_server_returning_jwt(workspace: &str) -> MockServer {
let mut mocks = MockSet::new();
let jwt = jwt_with_workspace(workspace);
mocks.mock(move |when, then| {
when.post().path("/api/authorise");
then.json(serde_json::json!({ "accessToken": jwt, "expiry": 3600 }));
});
let server =
MockServer::new_http("oidc-federation-strategy-workspace-test").with_mocks(mocks);
server.start().await.expect("mock server start");
server
}
fn provider() -> OidcProviderFn<impl Fn() -> std::future::Ready<Result<SecretToken, AuthError>>>
{
OidcProviderFn::new(|| {
std::future::ready(Ok(SecretToken::new("header.payload.signature".to_string())))
})
}
const WS: &str = "ZVATKW3VHMFG27DY";
mod maybe_base_url {
use super::*;
#[test]
fn absent_is_a_noop() {
let b = OidcFederationStrategy::builder(crn_with_workspace(WS), provider())
.maybe_base_url(None)
.unwrap();
assert!(b.base_url_override.is_none());
}
#[test]
fn empty_string_is_a_noop() {
let b = OidcFederationStrategy::builder(crn_with_workspace(WS), provider())
.maybe_base_url(Some(String::new()))
.unwrap();
assert!(b.base_url_override.is_none());
}
#[test]
fn valid_url_sets_the_override() {
let b = OidcFederationStrategy::builder(crn_with_workspace(WS), provider())
.maybe_base_url(Some("https://cts.example.com".to_string()))
.unwrap();
assert_eq!(
b.base_url_override.as_ref().map(url::Url::as_str),
Some("https://cts.example.com/")
);
}
#[test]
fn malformed_url_is_invalid_url() {
match OidcFederationStrategy::builder(crn_with_workspace(WS), provider())
.maybe_base_url(Some("not a url".to_string()))
{
Err(AuthError::InvalidUrl(_)) => {}
Ok(_) => panic!("expected Err(InvalidUrl), got Ok"),
Err(other) => panic!("expected InvalidUrl, got: {other:?}"),
}
}
}
#[tokio::test]
async fn base_url_override_takes_precedence_over_cs_cts_host() {
const WS: &str = "ZVATKW3VHMFG27DY";
let server = start_mock_server_returning_jwt(WS).await;
let strategy = temp_env::with_var("CS_CTS_HOST", Some("http://127.0.0.1:1/"), || {
OidcFederationStrategy::builder(crn_with_workspace(WS), provider())
.maybe_base_url(Some(server.url("").to_string()))
.expect("override URL parses")
.build()
.expect("builder")
});
let token = (&strategy)
.get_token()
.await
.expect("override must win: federation should hit the mock, not CS_CTS_HOST");
assert_eq!(
token.workspace_id().expect("workspace_id").as_str(),
WS,
"token should come from the override's mock server",
);
}
#[tokio::test]
async fn returns_token_when_workspace_matches() {
const WS: &str = "ZVATKW3VHMFG27DY";
let server = start_mock_server_returning_jwt(WS).await;
let strategy = OidcFederationStrategy::builder(crn_with_workspace(WS), provider())
.base_url(server.url(""))
.build()
.expect("builder");
let token = (&strategy).get_token().await.expect("get_token");
assert_eq!(
token.workspace_id().expect("workspace_id").as_str(),
WS,
"happy-path token should carry the expected workspace",
);
}
#[tokio::test]
async fn accepts_crn_with_service_name() {
const WS: &str = "ZVATKW3VHMFG27DY";
let server = start_mock_server_returning_jwt(WS).await;
let crn: Crn = format!("crn:ap-southeast-2.aws:{WS}:zerokms")
.parse()
.expect("CRN with service_name parses");
let strategy = OidcFederationStrategy::builder(crn, provider())
.base_url(server.url(""))
.build()
.expect("CRN with service_name should construct a strategy");
let token = (&strategy).get_token().await.expect("get_token");
assert_eq!(
token.workspace_id().expect("workspace_id").as_str(),
WS,
"service_name is ignored — verification still uses the workspace ID",
);
}
#[tokio::test]
async fn errors_when_token_workspace_differs() {
const TOKEN_WS: &str = "AAAAAAAAAAAAAAAA";
const EXPECTED_WS: &str = "ZVATKW3VHMFG27DY";
let server = start_mock_server_returning_jwt(TOKEN_WS).await;
let strategy = OidcFederationStrategy::builder(crn_with_workspace(EXPECTED_WS), provider())
.base_url(server.url(""))
.build()
.expect("builder");
let err = (&strategy)
.get_token()
.await
.expect_err("expected mismatch");
match err {
AuthError::WorkspaceMismatch(crate::error::WorkspaceMismatch {
expected_workspace,
token_workspace,
}) => {
assert_eq!(expected_workspace.as_str(), EXPECTED_WS);
assert_eq!(token_workspace.as_str(), TOKEN_WS);
}
other => panic!("expected WorkspaceMismatch, got {other:?}"),
}
}
#[tokio::test]
async fn errors_with_invalid_token_when_jwt_malformed() {
let mut mocks = MockSet::new();
mocks.mock(|when, then| {
when.post().path("/api/authorise");
then.json(serde_json::json!({ "accessToken": "not-a-jwt", "expiry": 3600 }));
});
let server =
MockServer::new_http("oidc-federation-strategy-malformed-test").with_mocks(mocks);
server.start().await.expect("mock server start");
let strategy =
OidcFederationStrategy::builder(crn_with_workspace("ZVATKW3VHMFG27DY"), provider())
.base_url(server.url(""))
.build()
.expect("builder");
let err = (&strategy)
.get_token()
.await
.expect_err("expected invalid-token error");
assert!(
matches!(err, AuthError::InvalidToken(_)),
"expected InvalidToken, got {err:?}",
);
}
#[tokio::test]
async fn rejects_stored_token_for_different_workspace() {
const TOKEN_WS: &str = "AAAAAAAAAAAAAAAA";
const EXPECTED_WS: &str = "ZVATKW3VHMFG27DY";
let mut mocks = MockSet::new();
mocks.mock(|when, then| {
when.post().path("/api/authorise");
then.internal_server_error()
.json(serde_json::json!({"error": "store must satisfy the request"}));
});
let server =
MockServer::new_http("oidc-federation-strategy-store-mismatch-test").with_mocks(mocks);
server.start().await.expect("mock server start");
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock")
.as_secs();
let stored = Token {
access_token: SecretToken::new(jwt_with_workspace(TOKEN_WS)),
token_type: "Bearer".to_string(),
expires_at: now + 3600,
refresh_token: None,
region: None,
client_id: None,
device_instance_id: None,
};
let store = Arc::new(InMemoryTokenStore::new());
store.save(&stored).await;
let strategy = OidcFederationStrategy::builder(crn_with_workspace(EXPECTED_WS), provider())
.base_url(server.url(""))
.with_token_store(Arc::clone(&store))
.build()
.expect("builder");
let err = (&strategy)
.get_token()
.await
.expect_err("expected mismatch from stored token");
assert!(
matches!(err, AuthError::WorkspaceMismatch { .. }),
"expected WorkspaceMismatch, got {err:?}",
);
}
#[tokio::test]
async fn errors_on_each_subsequent_get_token_call() {
const TOKEN_WS: &str = "AAAAAAAAAAAAAAAA";
const EXPECTED_WS: &str = "ZVATKW3VHMFG27DY";
let server = start_mock_server_returning_jwt(TOKEN_WS).await;
let strategy = OidcFederationStrategy::builder(crn_with_workspace(EXPECTED_WS), provider())
.base_url(server.url(""))
.build()
.expect("builder");
for call in 1..=2 {
let err = match (&strategy).get_token().await {
Ok(_) => panic!("call {call}: expected Err, got Ok"),
Err(e) => e,
};
assert!(
matches!(err, AuthError::WorkspaceMismatch { .. }),
"call {call}: expected WorkspaceMismatch, got {err:?}",
);
}
}
}