From 8c900913623d01505d59d90bcf1a2ef83aa5475f Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Fri, 25 Sep 2026 22:52:17 -0400 Subject: [PATCH 1/3] feat(dgw): add a TASK token for background tasks Co-Authored-By: Claude Opus 5.5 (1M context) --- devolutions-gateway/src/token.rs | 185 +++++++++++++++++- tools/tokengen/src/lib.rs | 40 ++++ tools/tokengen/src/main.rs | 19 +- tools/tokengen/src/server/server_impl.rs | 31 ++- .../JsonSerializationTests.cs | 32 +++ .../src/AccessScope.cs | 1 + .../src/TaskClaims.cs | 50 +++++ 7 files changed, 352 insertions(+), 6 deletions(-) create mode 100644 utils/dotnet/Devolutions.Gateway.Utils/src/TaskClaims.cs diff --git a/devolutions-gateway/src/token.rs b/devolutions-gateway/src/token.rs index bc0b74b4d..412576eeb 100644 --- a/devolutions-gateway/src/token.rs +++ b/devolutions-gateway/src/token.rs @@ -55,6 +55,7 @@ pub enum ContentType { WebApp, NetScan, Enrollment, + Task, } impl FromStr for ContentType { @@ -72,6 +73,7 @@ impl FromStr for ContentType { "WEBAPP" => Ok(ContentType::WebApp), "NETSCAN" => Ok(ContentType::NetScan), "ENROLLMENT" => Ok(ContentType::Enrollment), + "TASK" => Ok(ContentType::Task), unexpected => Err(BadContentType { value: SmolStr::new(unexpected), }), @@ -92,6 +94,7 @@ impl fmt::Display for ContentType { ContentType::WebApp => write!(f, "WEBAPP"), ContentType::NetScan => write!(f, "NETSCAN"), ContentType::Enrollment => write!(f, "ENROLLMENT"), + ContentType::Task => write!(f, "TASK"), } } } @@ -125,6 +128,7 @@ pub enum AccessTokenClaims { WebApp(WebAppTokenClaims), NetScan(NetScanClaims), Enrollment(EnrollmentTokenClaims), + Task(TaskTokenClaims), } impl AccessTokenClaims { @@ -140,6 +144,7 @@ impl AccessTokenClaims { AccessTokenClaims::WebApp(_) => false, AccessTokenClaims::NetScan(_) => false, AccessTokenClaims::Enrollment(_) => false, + AccessTokenClaims::Task(_) => false, } } } @@ -511,6 +516,8 @@ pub enum AccessScope { AgentDelete, #[serde(rename = "gateway.agent.read")] AgentRead, + #[serde(rename = "gateway.tasks.read")] + TasksRead, } #[derive(Clone, Serialize, Deserialize)] @@ -547,6 +554,36 @@ pub struct EnrollmentTokenClaims { pub jet_agent_name: String, } +// ----- task claims ----- // + +/// Kind of background task, with the target that this kind works on. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(tag = "jet_tk")] +pub enum TaskKind { + /// Describe what the user did in one session and store the result as a new log of that session. + #[serde(rename = "ai-log")] + AiLog { + /// Association ID (= Session ID) of the session to describe. + jet_aid: Uuid, + }, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct TaskTokenClaims { + #[serde(flatten)] + pub kind: TaskKind, + + /// Whether the token can be used more than once before it expires. + #[serde(default)] + pub jet_task_reuse: bool, + + /// JWT expiration time claim. + pub exp: i64, + + /// JWT "JWT ID" claim, the unique ID for this token. + pub jti: Uuid, +} + // ----- bridge claims ----- // #[derive(Clone)] @@ -1008,7 +1045,8 @@ fn validate_token_impl( | ContentType::Kdc | ContentType::WebApp | ContentType::NetScan - | ContentType::Enrollment => jwt.validate::(&strict_validator)?.state.claims, + | ContentType::Enrollment + | ContentType::Task => jwt.validate::(&strict_validator)?.state.claims, ContentType::Jrl => { // NOTE: JRL tokens are not expected to expire. // However, `iat` (Issued At) claim is required, and only more recent tokens will @@ -1095,6 +1133,7 @@ fn validate_token_impl( ContentType::WebApp => serde_json::from_value(claims).map(AccessTokenClaims::WebApp), ContentType::NetScan => serde_json::from_value(claims).map(AccessTokenClaims::NetScan), ContentType::Enrollment => serde_json::from_value(claims).map(AccessTokenClaims::Enrollment), + ContentType::Task => serde_json::from_value(claims).map(AccessTokenClaims::Task), } .map_err(|source| TokenError::InvalidClaimScheme { content_type, source })?; @@ -1179,10 +1218,16 @@ fn validate_token_impl( } } - // SCOPE, NETSCAN, and JMUX tokens can never be reused. + // SCOPE, NETSCAN, JMUX, and single-use TASK tokens can never be reused. AccessTokenClaims::Scope(ScopeTokenClaims { jti: id, exp, .. }) | AccessTokenClaims::NetScan(NetScanClaims { jti: id, exp, .. }) - | AccessTokenClaims::Jmux(JmuxTokenClaims { jti: id, exp, .. }) => match token_cache.lock().entry(id) { + | AccessTokenClaims::Jmux(JmuxTokenClaims { jti: id, exp, .. }) + | AccessTokenClaims::Task(TaskTokenClaims { + jti: id, + exp, + jet_task_reuse: false, + .. + }) => match token_cache.lock().entry(id) { Entry::Occupied(_) => { return Err(TokenError::UnexpectedReplay { reason: "never allowed for this use case", @@ -1233,10 +1278,13 @@ fn validate_token_impl( } }, - // JREC pull tokens can be re-used at will until they are expired. + // JREC pull tokens and reusable TASK tokens can be re-used at will until they are expired. AccessTokenClaims::Jrec(JrecTokenClaims { jet_rop: RecordingOperation::Pull, .. + }) + | AccessTokenClaims::Task(TaskTokenClaims { + jet_task_reuse: true, .. }) => {} // BRIDGE tokens can be re-used at will until they are expired. @@ -1444,6 +1492,7 @@ pub mod unsafe_debug { ContentType::WebApp => serde_json::from_value(claims).map(AccessTokenClaims::WebApp), ContentType::NetScan => serde_json::from_value(claims).map(AccessTokenClaims::NetScan), ContentType::Enrollment => serde_json::from_value(claims).map(AccessTokenClaims::Enrollment), + ContentType::Task => serde_json::from_value(claims).map(AccessTokenClaims::Task), } .map_err(|source| TokenError::InvalidClaimScheme { content_type, source })?; @@ -1927,4 +1976,132 @@ mod tests { assert_eq!(recording_file_type.content_type(), expected_content_type); } } + + struct TaskTokenFixture { + provisioner_key: PrivateKey, + token_cache: TokenCache, + revocation_list: CurrentJrl, + active_recordings: Arc, + } + + impl TaskTokenFixture { + fn new() -> Self { + let (sender, _) = crate::recording::recording_message_channel(); + + Self { + provisioner_key: PrivateKey::generate_ec(picky::key::EcCurve::NistP256).expect("generate EC key"), + token_cache: new_token_cache(), + revocation_list: Mutex::new(JrlTokenClaims::default()), + active_recordings: sender.active_recordings, + } + } + + fn sign(&self, claims: &serde_json::Value) -> String { + picky::jose::jwt::CheckedJwtSig::new_with_cty(picky::jose::jws::JwsAlg::ES256, "TASK", claims) + .encode(&self.provisioner_key) + .expect("sign TASK token") + } + + fn validate(&self, token: &str, gw_id: Option) -> Result { + TokenValidator::builder() + .source_ip(IpAddr::from([127, 0, 0, 1])) + .provisioner_key(&self.provisioner_key.to_public_key().expect("public key")) + .token_cache(&self.token_cache) + .revocation_list(&self.revocation_list) + .active_recordings(&self.active_recordings) + .delegation_key(None) + .subkey(None) + .gw_id(gw_id) + .disconnected_info(None) + .build() + .validate(token) + } + } + + fn task_claims(extra: serde_json::Value) -> serde_json::Value { + let mut claims = serde_json::json!({ + "jet_tk": "ai-log", + "jet_aid": "5e3e833f-84c7-4541-b676-acc3299e39b8", + "nbf": time::OffsetDateTime::now_utc().unix_timestamp(), + "exp": time::OffsetDateTime::now_utc().unix_timestamp() + 600, + "jti": Uuid::new_v4(), + }); + + claims + .as_object_mut() + .expect("object") + .extend(extra.as_object().expect("object").clone()); + + claims + } + + #[test] + fn task_token_claims_parse() { + let fixture = TaskTokenFixture::new(); + let token = fixture.sign(&task_claims(serde_json::json!({}))); + + let claims = fixture.validate(&token, None).expect("valid TASK token"); + + let AccessTokenClaims::Task(claims) = claims else { + panic!("expected TASK claims"); + }; + assert_eq!( + claims.kind, + TaskKind::AiLog { + jet_aid: Uuid::parse_str("5e3e833f-84c7-4541-b676-acc3299e39b8").expect("UUID"), + } + ); + assert!(!claims.jet_task_reuse); + } + + #[test] + fn single_use_task_token_is_rejected_on_second_use() { + let fixture = TaskTokenFixture::new(); + + for claims in [ + task_claims(serde_json::json!({})), + task_claims(serde_json::json!({ "jet_task_reuse": false })), + ] { + let token = fixture.sign(&claims); + + fixture.validate(&token, None).expect("first use"); + let error = fixture.validate(&token, None).err().expect("second use is rejected"); + + assert!(matches!(error, TokenError::UnexpectedReplay { .. }), "{error:?}"); + } + } + + #[test] + fn reusable_task_token_is_accepted_twice() { + let fixture = TaskTokenFixture::new(); + let token = fixture.sign(&task_claims(serde_json::json!({ "jet_task_reuse": true }))); + + fixture.validate(&token, None).expect("first use"); + fixture.validate(&token, None).expect("second use"); + } + + #[test] + fn unknown_task_kind_is_rejected() { + let fixture = TaskTokenFixture::new(); + let token = fixture.sign(&task_claims(serde_json::json!({ "jet_tk": "monitoring" }))); + + let error = fixture.validate(&token, None).err().expect("unknown kind is rejected"); + + assert!(matches!(error, TokenError::InvalidClaimScheme { .. }), "{error:?}"); + } + + #[test] + fn task_token_honors_gateway_id_scope() { + let fixture = TaskTokenFixture::new(); + let gw_id = Uuid::new_v4(); + let token = fixture.sign(&task_claims(serde_json::json!({ "jet_gw_id": gw_id }))); + + let error = fixture + .validate(&token, Some(Uuid::new_v4())) + .err() + .expect("other gateway is rejected"); + assert!(matches!(error, TokenError::GatewayIdScopeMismatch), "{error:?}"); + + fixture.validate(&token, Some(gw_id)).expect("this gateway is accepted"); + } } diff --git a/tools/tokengen/src/lib.rs b/tools/tokengen/src/lib.rs index f013f1ffd..eaad6efb6 100644 --- a/tools/tokengen/src/lib.rs +++ b/tools/tokengen/src/lib.rs @@ -150,6 +150,18 @@ pub struct NetScanClaim { pub jet_gw_id: Option, } +#[derive(Clone, Serialize)] +pub struct TaskClaims { + pub jet_tk: TaskKind, + pub jet_aid: Uuid, + pub jet_task_reuse: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub jet_gw_id: Option, + pub exp: i64, + pub nbf: i64, + pub jti: Uuid, +} + // --- Enums --- // #[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq)] @@ -219,9 +231,16 @@ macro_rules! impl_from_str { }; } +#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum TaskKind { + AiLog, +} + impl_from_str!(ApplicationProtocol); impl_from_str!(RecordingOperation); impl_from_str!(RecordingPolicy); +impl_from_str!(TaskKind); // --- SubCommandArgs Enum --- // @@ -285,6 +304,11 @@ pub enum SubCommandArgs { revoked_jti_list: Vec, }, NetScan {}, + Task { + jet_tk: TaskKind, + jet_aid: Option, + jet_task_reuse: bool, + }, } pub fn generate_token( @@ -549,6 +573,22 @@ pub fn generate_token( }; ("NETSCAN", serde_json::to_value(claims)?) } + SubCommandArgs::Task { + jet_tk, + jet_aid, + jet_task_reuse, + } => { + let claims = TaskClaims { + jet_tk, + jet_aid: jet_aid.unwrap_or_else(Uuid::new_v4), + jet_task_reuse, + jet_gw_id, + exp, + nbf, + jti, + }; + ("TASK", serde_json::to_value(claims)?) + } }; let mut jwt_sig = CheckedJwtSig::new_with_cty(JwsAlg::RS256, cty, claims); diff --git a/tools/tokengen/src/main.rs b/tools/tokengen/src/main.rs index d54a591ed..8e9266d98 100644 --- a/tools/tokengen/src/main.rs +++ b/tools/tokengen/src/main.rs @@ -2,7 +2,7 @@ use std::error::Error; use std::path::{Path, PathBuf}; use clap::{Parser, Subcommand}; -use tokengen::{ApplicationProtocol, RecordingOperation, SubCommandArgs, generate_token}; +use tokengen::{ApplicationProtocol, RecordingOperation, SubCommandArgs, TaskKind, generate_token}; use uuid::Uuid; fn main() -> Result<(), Box> { @@ -140,6 +140,15 @@ fn sign( }, SignSubCommand::Jrl { jti } => SubCommandArgs::Jrl { revoked_jti_list: jti }, SignSubCommand::NetScan {} => SubCommandArgs::NetScan {}, + SignSubCommand::Task { + jet_tk, + jet_aid, + jet_task_reuse, + } => SubCommandArgs::Task { + jet_tk, + jet_aid, + jet_task_reuse, + }, }; let validity_duration = humantime::parse_duration(validity_duration)?; @@ -287,4 +296,12 @@ enum SignSubCommand { jti: Vec, }, NetScan {}, + Task { + #[clap(long)] + jet_tk: TaskKind, + #[clap(long)] + jet_aid: Option, + #[clap(long)] + jet_task_reuse: bool, + }, } diff --git a/tools/tokengen/src/server/server_impl.rs b/tools/tokengen/src/server/server_impl.rs index 6be3eb125..bc981e6b2 100644 --- a/tools/tokengen/src/server/server_impl.rs +++ b/tools/tokengen/src/server/server_impl.rs @@ -9,7 +9,7 @@ use axum::routing::post; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::{ApplicationProtocol, RecordingOperation, SubCommandArgs, generate_token}; +use crate::{ApplicationProtocol, RecordingOperation, SubCommandArgs, TaskKind, generate_token}; pub(crate) fn create_router(provisioner_key_path: Arc, delegation_key_path: Option) -> Router { Router::new() @@ -23,6 +23,7 @@ pub(crate) fn create_router(provisioner_key_path: Arc, delegation_key_p .route("/kdc", post(kdc_handler)) .route("/jrl", post(jrl_handler)) .route("/netscan", post(netscan_handler)) + .route("/task", post(task_handler)) .layer(Extension(provisioner_key_path)) .layer(Extension(delegation_key_path)) } @@ -267,6 +268,24 @@ pub(crate) async fn netscan_handler( .await } +pub(crate) async fn task_handler( + Extension(provisioner_key_path): Extension>, + Extension(delegation_key_path): Extension>, + Json(request): Json, +) -> Result, (axum::http::StatusCode, String)> { + handle_subcommand( + provisioner_key_path, + delegation_key_path, + request.common, + SubCommandArgs::Task { + jet_tk: request.jet_tk, + jet_aid: request.jet_aid, + jet_task_reuse: request.jet_task_reuse, + }, + ) + .await +} + async fn handle_subcommand( provisioner_key_path: Arc, delegation_key_path: Option, @@ -387,3 +406,13 @@ pub(crate) struct NetScanRequest { #[serde(flatten)] common: CommonRequest, } + +#[derive(Deserialize)] +pub(crate) struct TaskRequest { + #[serde(flatten)] + common: CommonRequest, + jet_tk: TaskKind, + jet_aid: Option, + #[serde(default)] + jet_task_reuse: bool, +} diff --git a/utils/dotnet/Devolutions.Gateway.Utils.Tests/JsonSerializationTests.cs b/utils/dotnet/Devolutions.Gateway.Utils.Tests/JsonSerializationTests.cs index 30f165d40..c325bdcbd 100644 --- a/utils/dotnet/Devolutions.Gateway.Utils.Tests/JsonSerializationTests.cs +++ b/utils/dotnet/Devolutions.Gateway.Utils.Tests/JsonSerializationTests.cs @@ -186,6 +186,38 @@ public void ScopeClaimsAgentRead() Assert.Equal(EXPECTED, result); } + [Fact] + public void ScopeClaimsTasksRead() + { + const string EXPECTED = """{"scope":"gateway.tasks.read","jet_gw_id":"ccbaad3f-4627-4666-8bb5-cb6a1a7db815"}"""; + + var claims = new ScopeClaims(gatewayId, AccessScope.GatewayTasksRead); + string result = JsonSerializer.Serialize(claims); + Assert.Equal(EXPECTED, result); + } + + [Fact] + public void TaskClaimsForAiLog() + { + const string EXPECTED = """{"jet_tk":"ai-log","jet_aid":"3e7c1854-f1eb-42d2-b9cb-9303036e50da","jet_task_reuse":false,"jet_gw_id":"ccbaad3f-4627-4666-8bb5-cb6a1a7db815"}"""; + + var claims = TaskClaims.ForAiLog(gatewayId, sessionId); + string result = JsonSerializer.Serialize(claims); + Assert.Equal(EXPECTED, result); + Assert.Equal("TASK", claims.GetContentType()); + Assert.Equal(600, claims.GetDefaultLifetime()); + } + + [Fact] + public void TaskClaimsForReusableAiLog() + { + const string EXPECTED = """{"jet_tk":"ai-log","jet_aid":"3e7c1854-f1eb-42d2-b9cb-9303036e50da","jet_task_reuse":true,"jet_gw_id":"ccbaad3f-4627-4666-8bb5-cb6a1a7db815"}"""; + + var claims = TaskClaims.ForAiLog(gatewayId, sessionId, reusable: true); + string result = JsonSerializer.Serialize(claims); + Assert.Equal(EXPECTED, result); + } + [Fact] public void EnrollmentClaimsAllFields() { diff --git a/utils/dotnet/Devolutions.Gateway.Utils/src/AccessScope.cs b/utils/dotnet/Devolutions.Gateway.Utils/src/AccessScope.cs index bc953a2b1..3545de781 100644 --- a/utils/dotnet/Devolutions.Gateway.Utils/src/AccessScope.cs +++ b/utils/dotnet/Devolutions.Gateway.Utils/src/AccessScope.cs @@ -30,6 +30,7 @@ internal AccessScope(string value) public static AccessScope GatewayNetMonitorDrain = new AccessScope("gateway.net.monitor.drain"); public static AccessScope GatewayAgentDelete = new AccessScope("gateway.agent.delete"); public static AccessScope GatewayAgentRead = new AccessScope("gateway.agent.read"); + public static AccessScope GatewayTasksRead = new AccessScope("gateway.tasks.read"); public override string? ToString() { diff --git a/utils/dotnet/Devolutions.Gateway.Utils/src/TaskClaims.cs b/utils/dotnet/Devolutions.Gateway.Utils/src/TaskClaims.cs new file mode 100644 index 000000000..9a5b4625e --- /dev/null +++ b/utils/dotnet/Devolutions.Gateway.Utils/src/TaskClaims.cs @@ -0,0 +1,50 @@ +using System.Text.Json.Serialization; + +namespace Devolutions.Gateway.Utils; + +public class TaskClaims : IGatewayClaims +{ + [JsonPropertyName("jet_tk")] + public string TaskKind { get; set; } + + [JsonPropertyName("jet_aid")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Guid? SessionId { get; set; } + + [JsonPropertyName("jet_task_reuse")] + public bool Reusable { get; set; } + + [JsonPropertyName("jet_gw_id")] + public Guid ScopeGatewayId { get; set; } + + private TaskClaims(Guid scopeGatewayId, string taskKind, bool reusable) + { + this.ScopeGatewayId = scopeGatewayId; + this.TaskKind = taskKind; + this.Reusable = reusable; + } + + /// + /// Build the claims of a task that describes what the user did in one session and stores the result as a new log of that session. + /// + /// Target Gateway identifier. + /// Session to describe. + /// When true, the token can be used until it expires; otherwise it is single use. + public static TaskClaims ForAiLog(Guid scopeGatewayId, Guid sessionId, bool reusable = false) + { + return new TaskClaims(scopeGatewayId, "ai-log", reusable) + { + SessionId = sessionId, + }; + } + + public string GetContentType() + { + return "TASK"; + } + + public long? GetDefaultLifetime() + { + return 600; + } +} From 004a29926248232ec2d46adfe16259bb569be2a8 Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Sun, 27 Sep 2026 13:31:04 -0400 Subject: [PATCH 2/3] refactor(dgw): make TASK tokens always single use Remove the `jet_task_reuse` claim from the Gateway, the .NET `TaskClaims`, and tokengen. The token cache now rejects any second use of a TASK token. Reuse can come back later as an optional claim without breaking anyone. Co-Authored-By: Claude Opus 5.5 (1M context) --- devolutions-gateway/src/token.rs | 44 ++++--------------- tools/tokengen/src/lib.rs | 9 +--- tools/tokengen/src/main.rs | 12 +---- tools/tokengen/src/server/server_impl.rs | 3 -- .../JsonSerializationTests.cs | 12 +---- .../src/TaskClaims.cs | 11 ++--- 6 files changed, 14 insertions(+), 77 deletions(-) diff --git a/devolutions-gateway/src/token.rs b/devolutions-gateway/src/token.rs index 412576eeb..afc2c84ab 100644 --- a/devolutions-gateway/src/token.rs +++ b/devolutions-gateway/src/token.rs @@ -573,10 +573,6 @@ pub struct TaskTokenClaims { #[serde(flatten)] pub kind: TaskKind, - /// Whether the token can be used more than once before it expires. - #[serde(default)] - pub jet_task_reuse: bool, - /// JWT expiration time claim. pub exp: i64, @@ -1218,16 +1214,11 @@ fn validate_token_impl( } } - // SCOPE, NETSCAN, JMUX, and single-use TASK tokens can never be reused. + // SCOPE, NETSCAN, JMUX, and TASK tokens can never be reused. AccessTokenClaims::Scope(ScopeTokenClaims { jti: id, exp, .. }) | AccessTokenClaims::NetScan(NetScanClaims { jti: id, exp, .. }) | AccessTokenClaims::Jmux(JmuxTokenClaims { jti: id, exp, .. }) - | AccessTokenClaims::Task(TaskTokenClaims { - jti: id, - exp, - jet_task_reuse: false, - .. - }) => match token_cache.lock().entry(id) { + | AccessTokenClaims::Task(TaskTokenClaims { jti: id, exp, .. }) => match token_cache.lock().entry(id) { Entry::Occupied(_) => { return Err(TokenError::UnexpectedReplay { reason: "never allowed for this use case", @@ -1278,13 +1269,10 @@ fn validate_token_impl( } }, - // JREC pull tokens and reusable TASK tokens can be re-used at will until they are expired. + // JREC pull tokens can be re-used at will until they are expired. AccessTokenClaims::Jrec(JrecTokenClaims { jet_rop: RecordingOperation::Pull, .. - }) - | AccessTokenClaims::Task(TaskTokenClaims { - jet_task_reuse: true, .. }) => {} // BRIDGE tokens can be re-used at will until they are expired. @@ -2051,33 +2039,17 @@ mod tests { jet_aid: Uuid::parse_str("5e3e833f-84c7-4541-b676-acc3299e39b8").expect("UUID"), } ); - assert!(!claims.jet_task_reuse); - } - - #[test] - fn single_use_task_token_is_rejected_on_second_use() { - let fixture = TaskTokenFixture::new(); - - for claims in [ - task_claims(serde_json::json!({})), - task_claims(serde_json::json!({ "jet_task_reuse": false })), - ] { - let token = fixture.sign(&claims); - - fixture.validate(&token, None).expect("first use"); - let error = fixture.validate(&token, None).err().expect("second use is rejected"); - - assert!(matches!(error, TokenError::UnexpectedReplay { .. }), "{error:?}"); - } } #[test] - fn reusable_task_token_is_accepted_twice() { + fn task_token_is_rejected_on_second_use() { let fixture = TaskTokenFixture::new(); - let token = fixture.sign(&task_claims(serde_json::json!({ "jet_task_reuse": true }))); + let token = fixture.sign(&task_claims(serde_json::json!({}))); fixture.validate(&token, None).expect("first use"); - fixture.validate(&token, None).expect("second use"); + let error = fixture.validate(&token, None).err().expect("second use is rejected"); + + assert!(matches!(error, TokenError::UnexpectedReplay { .. }), "{error:?}"); } #[test] diff --git a/tools/tokengen/src/lib.rs b/tools/tokengen/src/lib.rs index eaad6efb6..13775fdb9 100644 --- a/tools/tokengen/src/lib.rs +++ b/tools/tokengen/src/lib.rs @@ -154,7 +154,6 @@ pub struct NetScanClaim { pub struct TaskClaims { pub jet_tk: TaskKind, pub jet_aid: Uuid, - pub jet_task_reuse: bool, #[serde(skip_serializing_if = "Option::is_none")] pub jet_gw_id: Option, pub exp: i64, @@ -307,7 +306,6 @@ pub enum SubCommandArgs { Task { jet_tk: TaskKind, jet_aid: Option, - jet_task_reuse: bool, }, } @@ -573,15 +571,10 @@ pub fn generate_token( }; ("NETSCAN", serde_json::to_value(claims)?) } - SubCommandArgs::Task { - jet_tk, - jet_aid, - jet_task_reuse, - } => { + SubCommandArgs::Task { jet_tk, jet_aid } => { let claims = TaskClaims { jet_tk, jet_aid: jet_aid.unwrap_or_else(Uuid::new_v4), - jet_task_reuse, jet_gw_id, exp, nbf, diff --git a/tools/tokengen/src/main.rs b/tools/tokengen/src/main.rs index 8e9266d98..4bb3d90f3 100644 --- a/tools/tokengen/src/main.rs +++ b/tools/tokengen/src/main.rs @@ -140,15 +140,7 @@ fn sign( }, SignSubCommand::Jrl { jti } => SubCommandArgs::Jrl { revoked_jti_list: jti }, SignSubCommand::NetScan {} => SubCommandArgs::NetScan {}, - SignSubCommand::Task { - jet_tk, - jet_aid, - jet_task_reuse, - } => SubCommandArgs::Task { - jet_tk, - jet_aid, - jet_task_reuse, - }, + SignSubCommand::Task { jet_tk, jet_aid } => SubCommandArgs::Task { jet_tk, jet_aid }, }; let validity_duration = humantime::parse_duration(validity_duration)?; @@ -301,7 +293,5 @@ enum SignSubCommand { jet_tk: TaskKind, #[clap(long)] jet_aid: Option, - #[clap(long)] - jet_task_reuse: bool, }, } diff --git a/tools/tokengen/src/server/server_impl.rs b/tools/tokengen/src/server/server_impl.rs index bc981e6b2..5e3980f9b 100644 --- a/tools/tokengen/src/server/server_impl.rs +++ b/tools/tokengen/src/server/server_impl.rs @@ -280,7 +280,6 @@ pub(crate) async fn task_handler( SubCommandArgs::Task { jet_tk: request.jet_tk, jet_aid: request.jet_aid, - jet_task_reuse: request.jet_task_reuse, }, ) .await @@ -413,6 +412,4 @@ pub(crate) struct TaskRequest { common: CommonRequest, jet_tk: TaskKind, jet_aid: Option, - #[serde(default)] - jet_task_reuse: bool, } diff --git a/utils/dotnet/Devolutions.Gateway.Utils.Tests/JsonSerializationTests.cs b/utils/dotnet/Devolutions.Gateway.Utils.Tests/JsonSerializationTests.cs index c325bdcbd..d92e3eeba 100644 --- a/utils/dotnet/Devolutions.Gateway.Utils.Tests/JsonSerializationTests.cs +++ b/utils/dotnet/Devolutions.Gateway.Utils.Tests/JsonSerializationTests.cs @@ -199,7 +199,7 @@ public void ScopeClaimsTasksRead() [Fact] public void TaskClaimsForAiLog() { - const string EXPECTED = """{"jet_tk":"ai-log","jet_aid":"3e7c1854-f1eb-42d2-b9cb-9303036e50da","jet_task_reuse":false,"jet_gw_id":"ccbaad3f-4627-4666-8bb5-cb6a1a7db815"}"""; + const string EXPECTED = """{"jet_tk":"ai-log","jet_aid":"3e7c1854-f1eb-42d2-b9cb-9303036e50da","jet_gw_id":"ccbaad3f-4627-4666-8bb5-cb6a1a7db815"}"""; var claims = TaskClaims.ForAiLog(gatewayId, sessionId); string result = JsonSerializer.Serialize(claims); @@ -208,16 +208,6 @@ public void TaskClaimsForAiLog() Assert.Equal(600, claims.GetDefaultLifetime()); } - [Fact] - public void TaskClaimsForReusableAiLog() - { - const string EXPECTED = """{"jet_tk":"ai-log","jet_aid":"3e7c1854-f1eb-42d2-b9cb-9303036e50da","jet_task_reuse":true,"jet_gw_id":"ccbaad3f-4627-4666-8bb5-cb6a1a7db815"}"""; - - var claims = TaskClaims.ForAiLog(gatewayId, sessionId, reusable: true); - string result = JsonSerializer.Serialize(claims); - Assert.Equal(EXPECTED, result); - } - [Fact] public void EnrollmentClaimsAllFields() { diff --git a/utils/dotnet/Devolutions.Gateway.Utils/src/TaskClaims.cs b/utils/dotnet/Devolutions.Gateway.Utils/src/TaskClaims.cs index 9a5b4625e..567fa0cf5 100644 --- a/utils/dotnet/Devolutions.Gateway.Utils/src/TaskClaims.cs +++ b/utils/dotnet/Devolutions.Gateway.Utils/src/TaskClaims.cs @@ -11,17 +11,13 @@ public class TaskClaims : IGatewayClaims [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public Guid? SessionId { get; set; } - [JsonPropertyName("jet_task_reuse")] - public bool Reusable { get; set; } - [JsonPropertyName("jet_gw_id")] public Guid ScopeGatewayId { get; set; } - private TaskClaims(Guid scopeGatewayId, string taskKind, bool reusable) + private TaskClaims(Guid scopeGatewayId, string taskKind) { this.ScopeGatewayId = scopeGatewayId; this.TaskKind = taskKind; - this.Reusable = reusable; } /// @@ -29,10 +25,9 @@ private TaskClaims(Guid scopeGatewayId, string taskKind, bool reusable) /// /// Target Gateway identifier. /// Session to describe. - /// When true, the token can be used until it expires; otherwise it is single use. - public static TaskClaims ForAiLog(Guid scopeGatewayId, Guid sessionId, bool reusable = false) + public static TaskClaims ForAiLog(Guid scopeGatewayId, Guid sessionId) { - return new TaskClaims(scopeGatewayId, "ai-log", reusable) + return new TaskClaims(scopeGatewayId, "ai-log") { SessionId = sessionId, }; From 24840daa62b059f608e5e224f24172ac64bce8e2 Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Sun, 27 Sep 2026 13:31:05 -0400 Subject: [PATCH 3/3] refactor(dotnet-utils): strongly type the task kind in TaskClaims Add a `TaskKind` struct with a JSON converter, in the same style as `RecordingOperation`, with `AiLog` serialized as "ai-log". Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/TaskClaims.cs | 6 +++--- .../Devolutions.Gateway.Utils/src/TaskKind.cs | 21 +++++++++++++++++++ .../src/TaskKindJsonConverter.cs | 17 +++++++++++++++ 3 files changed, 41 insertions(+), 3 deletions(-) create mode 100644 utils/dotnet/Devolutions.Gateway.Utils/src/TaskKind.cs create mode 100644 utils/dotnet/Devolutions.Gateway.Utils/src/TaskKindJsonConverter.cs diff --git a/utils/dotnet/Devolutions.Gateway.Utils/src/TaskClaims.cs b/utils/dotnet/Devolutions.Gateway.Utils/src/TaskClaims.cs index 567fa0cf5..9d50cd4a1 100644 --- a/utils/dotnet/Devolutions.Gateway.Utils/src/TaskClaims.cs +++ b/utils/dotnet/Devolutions.Gateway.Utils/src/TaskClaims.cs @@ -5,7 +5,7 @@ namespace Devolutions.Gateway.Utils; public class TaskClaims : IGatewayClaims { [JsonPropertyName("jet_tk")] - public string TaskKind { get; set; } + public TaskKind TaskKind { get; set; } [JsonPropertyName("jet_aid")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] @@ -14,7 +14,7 @@ public class TaskClaims : IGatewayClaims [JsonPropertyName("jet_gw_id")] public Guid ScopeGatewayId { get; set; } - private TaskClaims(Guid scopeGatewayId, string taskKind) + private TaskClaims(Guid scopeGatewayId, TaskKind taskKind) { this.ScopeGatewayId = scopeGatewayId; this.TaskKind = taskKind; @@ -27,7 +27,7 @@ private TaskClaims(Guid scopeGatewayId, string taskKind) /// Session to describe. public static TaskClaims ForAiLog(Guid scopeGatewayId, Guid sessionId) { - return new TaskClaims(scopeGatewayId, "ai-log") + return new TaskClaims(scopeGatewayId, TaskKind.AiLog) { SessionId = sessionId, }; diff --git a/utils/dotnet/Devolutions.Gateway.Utils/src/TaskKind.cs b/utils/dotnet/Devolutions.Gateway.Utils/src/TaskKind.cs new file mode 100644 index 000000000..7018c00af --- /dev/null +++ b/utils/dotnet/Devolutions.Gateway.Utils/src/TaskKind.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace Devolutions.Gateway.Utils; + +[JsonConverter(typeof(TaskKindJsonConverter))] +public struct TaskKind +{ + public string Value { get; internal set; } + + internal TaskKind(string value) + { + Value = value; + } + + public static TaskKind AiLog = new TaskKind("ai-log"); + + public override string? ToString() + { + return this.Value; + } +} diff --git a/utils/dotnet/Devolutions.Gateway.Utils/src/TaskKindJsonConverter.cs b/utils/dotnet/Devolutions.Gateway.Utils/src/TaskKindJsonConverter.cs new file mode 100644 index 000000000..bba7396a5 --- /dev/null +++ b/utils/dotnet/Devolutions.Gateway.Utils/src/TaskKindJsonConverter.cs @@ -0,0 +1,17 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Devolutions.Gateway.Utils; + +public class TaskKindJsonConverter : JsonConverter +{ + public override TaskKind Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options) => new TaskKind(reader.GetString()!); + + public override void Write( + Utf8JsonWriter writer, + TaskKind taskKind, + JsonSerializerOptions options) => writer.WriteStringValue(taskKind.ToString()); +}