Skip to content

Commit 1f130f8

Browse files
committed
feat(providers): mint oauth refresh token credentials
1 parent 0566106 commit 1f130f8

4 files changed

Lines changed: 145 additions & 22 deletions

File tree

crates/openshell-server/src/grpc/provider.rs

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -327,8 +327,7 @@ pub(super) async fn resolve_provider_environment(
327327

328328
let mut env = std::collections::HashMap::new();
329329
let mut expires = std::collections::HashMap::new();
330-
let now_ms = crate::persistence::current_time_ms()
331-
.map_err(|e| Status::internal(format!("timestamp error: {e}")))?;
330+
let now_ms = crate::persistence::current_time_ms();
332331

333332
for name in provider_names {
334333
let provider = store
@@ -1812,7 +1811,7 @@ mod tests {
18121811
.await
18131812
.unwrap();
18141813

1815-
let expires_at_ms = crate::persistence::current_time_ms().unwrap() + 60_000;
1814+
let expires_at_ms = crate::persistence::current_time_ms() + 60_000;
18161815
let response = handle_configure_provider_refresh(
18171816
&state,
18181817
Request::new(ConfigureProviderRefreshRequest {
@@ -2505,7 +2504,7 @@ mod tests {
25052504
#[tokio::test]
25062505
async fn resolve_provider_env_skips_expired_credentials_and_returns_expiry_metadata() {
25072506
let store = Store::connect("sqlite::memory:").await.unwrap();
2508-
let now_ms = crate::persistence::current_time_ms().unwrap();
2507+
let now_ms = crate::persistence::current_time_ms();
25092508
let provider = Provider {
25102509
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
25112510
id: String::new(),

crates/openshell-server/src/persistence/postgres.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ ON CONFLICT (object_type, name) WHERE name IS NOT NULL DO UPDATE SET
8585
payload: &[u8],
8686
labels: Option<&str>,
8787
) -> PersistenceResult<()> {
88-
let now_ms = current_time_ms()?;
88+
let now_ms = current_time_ms();
8989
let labels_jsonb: Option<serde_json::Value> = labels
9090
.map(serde_json::from_str)
9191
.transpose()

crates/openshell-server/src/persistence/sqlite.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ ON CONFLICT ("object_type", "name") WHERE "name" IS NOT NULL DO UPDATE SET
107107
payload: &[u8],
108108
labels: Option<&str>,
109109
) -> PersistenceResult<()> {
110-
let now_ms = current_time_ms()?;
110+
let now_ms = current_time_ms();
111111

112112
sqlx::query(
113113
r#"

crates/openshell-server/src/provider_refresh.rs

Lines changed: 140 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -185,8 +185,7 @@ pub fn new_refresh_state(
185185
) -> Result<StoredProviderCredentialRefreshState, Status> {
186186
let provider_id = provider.object_id().to_string();
187187
let provider_name = provider.object_name().to_string();
188-
let now_ms =
189-
current_time_ms().map_err(|e| Status::internal(format!("timestamp error: {e}")))?;
188+
let now_ms = current_time_ms();
190189
let next_refresh_at_ms = next_refresh_at_ms(
191190
config.expires_at_ms,
192191
config.refresh_before_seconds,
@@ -224,12 +223,14 @@ use openshell_core::{ObjectId, ObjectName};
224223
struct MintedCredential {
225224
access_token: String,
226225
expires_at_ms: i64,
226+
refresh_token: Option<String>,
227227
}
228228

229229
#[derive(Debug, Deserialize)]
230230
struct TokenResponse {
231231
access_token: String,
232232
expires_in: Option<i64>,
233+
refresh_token: Option<String>,
233234
}
234235

235236
#[derive(Debug, Serialize)]
@@ -307,9 +308,20 @@ pub async fn refresh_provider_credential(
307308

308309
match mint_credential(&state).await {
309310
Ok(minted) => {
310-
let now_ms =
311-
current_time_ms().map_err(|e| Status::internal(format!("timestamp error: {e}")))?;
311+
let now_ms = current_time_ms();
312312
apply_minted_credential(store, &provider, credential_key, &minted).await?;
313+
if let Some(refresh_token) = minted.refresh_token {
314+
state
315+
.material
316+
.insert("refresh_token".to_string(), refresh_token);
317+
if !state
318+
.secret_material_keys
319+
.iter()
320+
.any(|key| key == "refresh_token")
321+
{
322+
state.secret_material_keys.push("refresh_token".to_string());
323+
}
324+
}
313325
state.expires_at_ms = minted.expires_at_ms;
314326
state.next_refresh_at_ms = next_refresh_at_ms(
315327
minted.expires_at_ms,
@@ -334,8 +346,7 @@ pub async fn refresh_provider_credential(
334346
Ok(state)
335347
}
336348
Err(err) => {
337-
let now_ms =
338-
current_time_ms().map_err(|e| Status::internal(format!("timestamp error: {e}")))?;
349+
let now_ms = current_time_ms();
339350
state.status = "error".to_string();
340351
state.last_error = err.message().to_string();
341352
state.next_refresh_at_ms =
@@ -385,6 +396,9 @@ async fn mint_credential(
385396
let strategy = ProviderCredentialRefreshStrategy::try_from(state.strategy)
386397
.unwrap_or(ProviderCredentialRefreshStrategy::Unspecified);
387398
match strategy {
399+
ProviderCredentialRefreshStrategy::Oauth2RefreshToken => {
400+
mint_oauth2_refresh_token(state).await
401+
}
388402
ProviderCredentialRefreshStrategy::Oauth2ClientCredentials => {
389403
mint_oauth2_client_credentials(state).await
390404
}
@@ -393,13 +407,34 @@ async fn mint_credential(
393407
}
394408
ProviderCredentialRefreshStrategy::External
395409
| ProviderCredentialRefreshStrategy::Static
396-
| ProviderCredentialRefreshStrategy::Oauth2RefreshToken
397410
| ProviderCredentialRefreshStrategy::Unspecified => Err(Status::failed_precondition(
398411
format!("refresh strategy '{strategy:?}' cannot be minted by the gateway yet"),
399412
)),
400413
}
401414
}
402415

416+
async fn mint_oauth2_refresh_token(
417+
state: &StoredProviderCredentialRefreshState,
418+
) -> Result<MintedCredential, Status> {
419+
let token_url = oauth2_token_url(state)?;
420+
let client_id = required_material(&state.material, "client_id")?;
421+
let refresh_token = required_material(&state.material, "refresh_token")?;
422+
let mut form = vec![
423+
("grant_type".to_string(), "refresh_token".to_string()),
424+
("client_id".to_string(), client_id),
425+
("refresh_token".to_string(), refresh_token),
426+
];
427+
if let Some(client_secret) = material_value(&state.material, &["client_secret"]) {
428+
form.push(("client_secret".to_string(), client_secret));
429+
}
430+
let scope = refresh_scopes(state).join(" ");
431+
if !scope.is_empty() {
432+
form.push(("scope".to_string(), scope));
433+
}
434+
435+
request_token(&token_url, &form, state.max_lifetime_seconds).await
436+
}
437+
403438
async fn mint_oauth2_client_credentials(
404439
state: &StoredProviderCredentialRefreshState,
405440
) -> Result<MintedCredential, Status> {
@@ -431,8 +466,7 @@ async fn mint_google_service_account_jwt(
431466
"google_service_account_jwt requires at least one scope",
432467
));
433468
}
434-
let now_ms =
435-
current_time_ms().map_err(|e| Status::internal(format!("timestamp error: {e}")))?;
469+
let now_ms = current_time_ms();
436470
let now_secs = now_ms / 1000;
437471
let lifetime_secs = if state.max_lifetime_seconds > 0 {
438472
state.max_lifetime_seconds.min(DEFAULT_MAX_LIFETIME_SECONDS)
@@ -508,8 +542,7 @@ async fn request_token(
508542
"token endpoint returned empty access_token",
509543
));
510544
}
511-
let now_ms =
512-
current_time_ms().map_err(|e| Status::internal(format!("timestamp error: {e}")))?;
545+
let now_ms = current_time_ms();
513546
let lifetime_cap_seconds = if max_lifetime_seconds > 0 {
514547
max_lifetime_seconds
515548
} else {
@@ -523,6 +556,9 @@ async fn request_token(
523556
Ok(MintedCredential {
524557
access_token: token.access_token,
525558
expires_at_ms: now_ms.saturating_add(lifetime_seconds.saturating_mul(1000)),
559+
refresh_token: token
560+
.refresh_token
561+
.filter(|refresh_token| !refresh_token.trim().is_empty()),
526562
})
527563
}
528564

@@ -618,8 +654,7 @@ pub fn spawn_refresh_worker(state: std::sync::Arc<crate::ServerState>, interval:
618654
}
619655

620656
async fn run_refresh_worker_tick(store: &Store) -> Result<(), Status> {
621-
let now_ms =
622-
current_time_ms().map_err(|e| Status::internal(format!("timestamp error: {e}")))?;
657+
let now_ms = current_time_ms();
623658
let states = list_all_refresh_states(store).await?;
624659
let watched_count = states.len();
625660
let due_count = states
@@ -681,10 +716,11 @@ async fn run_refresh_worker_tick(store: &Store) -> Result<(), Status> {
681716
#[cfg(test)]
682717
mod tests {
683718
use super::{
684-
NewRefreshStateConfig, new_refresh_state, put_refresh_state, refresh_provider_credential,
685-
refresh_state_name, refresh_strategy_name, seconds_until_ms,
719+
NewRefreshStateConfig, get_refresh_state, new_refresh_state, put_refresh_state,
720+
refresh_provider_credential, refresh_state_name, refresh_strategy_name, seconds_until_ms,
686721
};
687722
use crate::persistence::Store;
723+
use openshell_core::ObjectId;
688724
use openshell_core::proto::datamodel::v1::ObjectMeta;
689725
use openshell_core::proto::{Provider, ProviderCredentialRefreshStrategy};
690726
use std::collections::HashMap;
@@ -714,6 +750,10 @@ mod tests {
714750
assert_eq!(seconds_until_ms(1_000, 61_000), 60);
715751
assert_eq!(seconds_until_ms(61_000, 1_000), 0);
716752
assert_eq!(seconds_until_ms(1_000, 0), 0);
753+
assert_eq!(
754+
refresh_strategy_name(ProviderCredentialRefreshStrategy::Oauth2RefreshToken as i32),
755+
"oauth2_refresh_token"
756+
);
717757
assert_eq!(
718758
refresh_strategy_name(
719759
ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32
@@ -752,7 +792,7 @@ mod tests {
752792
.unwrap();
753793
let provider = provider("my-graph", "outlook");
754794
store.put_message(&provider).await.unwrap();
755-
let before_refresh_ms = crate::persistence::current_time_ms().unwrap();
795+
let before_refresh_ms = crate::persistence::current_time_ms();
756796
let state = new_refresh_state(
757797
&provider,
758798
"MS_GRAPH_ACCESS_TOKEN",
@@ -797,6 +837,90 @@ mod tests {
797837
);
798838
}
799839

840+
#[tokio::test]
841+
async fn oauth2_refresh_token_refresh_mints_access_token_and_persists_rotated_refresh_token() {
842+
let mock_server = MockServer::start().await;
843+
Mock::given(method("POST"))
844+
.and(path("/token"))
845+
.and(body_string_contains("grant_type=refresh_token"))
846+
.and(body_string_contains("client_id=client-id"))
847+
.and(body_string_contains("refresh_token=old-refresh-token"))
848+
.and(body_string_contains(
849+
"scope=https%3A%2F%2Fgraph.microsoft.com%2F.default",
850+
))
851+
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
852+
"access_token": "delegated-graph-token",
853+
"refresh_token": "rotated-refresh-token",
854+
"expires_in": 3600,
855+
"token_type": "Bearer"
856+
})))
857+
.mount(&mock_server)
858+
.await;
859+
860+
let store = Store::connect("sqlite::memory:?cache=shared")
861+
.await
862+
.unwrap();
863+
let provider = provider("my-delegated-graph", "outlook");
864+
store.put_message(&provider).await.unwrap();
865+
let state = new_refresh_state(
866+
&provider,
867+
"MS_GRAPH_ACCESS_TOKEN",
868+
NewRefreshStateConfig {
869+
strategy: ProviderCredentialRefreshStrategy::Oauth2RefreshToken,
870+
material: HashMap::from([
871+
("client_id".to_string(), "client-id".to_string()),
872+
("refresh_token".to_string(), "old-refresh-token".to_string()),
873+
]),
874+
secret_material_keys: vec!["refresh_token".to_string()],
875+
expires_at_ms: 0,
876+
token_url: format!("{}/token", mock_server.uri()),
877+
scopes: vec!["https://graph.microsoft.com/.default".to_string()],
878+
refresh_before_seconds: 30,
879+
max_lifetime_seconds: 60,
880+
},
881+
)
882+
.unwrap();
883+
put_refresh_state(&store, &state).await.unwrap();
884+
885+
let refreshed =
886+
refresh_provider_credential(&store, "my-delegated-graph", "MS_GRAPH_ACCESS_TOKEN")
887+
.await
888+
.unwrap();
889+
assert_eq!(refreshed.status, "refreshed");
890+
assert!(refreshed.expires_at_ms > 0);
891+
892+
let stored_provider = store
893+
.get_message_by_name::<Provider>("my-delegated-graph")
894+
.await
895+
.unwrap()
896+
.unwrap();
897+
assert_eq!(
898+
stored_provider.credentials.get("MS_GRAPH_ACCESS_TOKEN"),
899+
Some(&"delegated-graph-token".to_string())
900+
);
901+
assert_eq!(
902+
stored_provider
903+
.credential_expires_at_ms
904+
.get("MS_GRAPH_ACCESS_TOKEN"),
905+
Some(&refreshed.expires_at_ms)
906+
);
907+
908+
let stored_state = get_refresh_state(&store, provider.object_id(), "MS_GRAPH_ACCESS_TOKEN")
909+
.await
910+
.unwrap()
911+
.unwrap();
912+
assert_eq!(
913+
stored_state.material.get("refresh_token"),
914+
Some(&"rotated-refresh-token".to_string())
915+
);
916+
assert!(
917+
stored_state
918+
.secret_material_keys
919+
.iter()
920+
.any(|key| key == "refresh_token")
921+
);
922+
}
923+
800924
#[tokio::test]
801925
async fn google_service_account_refresh_mints_and_persists_access_token() {
802926
let mock_server = MockServer::start().await;

0 commit comments

Comments
 (0)