Skip to content

Commit 3922d40

Browse files
refactor(token): collapse two enrollment scopes into agent.enroll
Two scopes had grown for the same concept now that DVLS mints the enrollment JWT itself: `gateway.tunnel.enroll` was the scope on the JWT presented by the agent at `/jet/tunnel/enroll`, and `gateway.agent.enroll` was the scope DVLS used when calling the removed `/jet/tunnel/enrollment-string` endpoint. With that endpoint gone, the second meaning is dead and the first is the only one that actually authorizes anything on the wire. Drop `AccessScope::TunnelEnroll` and have the gateway-side validator accept `AgentEnroll | Wildcard`. DVLS signs with `agent.enroll`. The .NET side gets a new `EnrollmentClaims` class that mirrors the Rust `EnrollmentTokenClaims` shape (scope + jet_gw_url + optional jet_agent_name + optional jet_quic_endpoint) so `TokenUtils.Sign` can emit the JWT directly. NuGet bumped to 2025.10.3.
1 parent b7c000e commit 3922d40

6 files changed

Lines changed: 68 additions & 16 deletions

File tree

devolutions-agent/src/cli_tests.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ fn make_jwt(payload: serde_json::Value) -> String {
8888
#[test]
8989
fn parse_up_command_args_accepts_enrollment_string() {
9090
let jwt = make_jwt(serde_json::json!({
91-
"scope": "gateway.tunnel.enroll",
91+
"scope": "gateway.agent.enroll",
9292
"exp": 1_999_999_999i64,
9393
"jti": "00000000-0000-0000-0000-000000000000",
9494
"jet_gw_url": "https://gateway.example.com:7171",
@@ -110,7 +110,7 @@ fn parse_up_command_args_accepts_enrollment_string() {
110110
fn parse_up_command_args_rejects_enrollment_string_missing_quic_endpoint() {
111111
// JWT lacks `jet_quic_endpoint` AND no CLI `--quic-endpoint` → must fail.
112112
let jwt = make_jwt(serde_json::json!({
113-
"scope": "gateway.tunnel.enroll",
113+
"scope": "gateway.agent.enroll",
114114
"exp": 1_999_999_999i64,
115115
"jti": "00000000-0000-0000-0000-000000000000",
116116
"jet_gw_url": "https://gateway.example.com:7171",
@@ -128,7 +128,7 @@ fn parse_up_command_args_rejects_enrollment_string_missing_quic_endpoint() {
128128
#[test]
129129
fn parse_up_command_args_cli_quic_endpoint_wins_over_jwt() {
130130
let jwt = make_jwt(serde_json::json!({
131-
"scope": "gateway.tunnel.enroll",
131+
"scope": "gateway.agent.enroll",
132132
"exp": 1_999_999_999i64,
133133
"jti": "00000000-0000-0000-0000-000000000000",
134134
"jet_gw_url": "https://gateway.example.com:7171",

devolutions-agent/src/enrollment.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -374,7 +374,7 @@ mod tests {
374374
#[test]
375375
fn parse_enrollment_jwt_requires_gw_url() {
376376
let jwt = make_jwt(serde_json::json!({
377-
"scope": "gateway.tunnel.enroll",
377+
"scope": "gateway.agent.enroll",
378378
"jet_agent_name": "agent-a",
379379
}));
380380
assert!(parse_enrollment_jwt(&jwt).is_err());

devolutions-gateway/src/api/tunnel.rs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ use crate::http::HttpError;
1212
///
1313
/// Returns `true` if the token is a well-formed JWT whose signature verifies
1414
/// against `provisioner_key`, whose `exp` has not passed, and whose `scope`
15-
/// is `TunnelEnroll` (or `Wildcard`). Returns `false` for any failure.
15+
/// is `AgentEnroll` (or `Wildcard`). Returns `false` for any failure.
1616
///
1717
/// The enrollment JWT carries extra claims (`jet_gw_url`, `jet_agent_name`)
1818
/// that the *agent* reads locally from its own copy of the token — the Gateway
@@ -40,7 +40,7 @@ fn validate_enrollment_jwt(token: &str, provisioner_key: &picky::key::PublicKey)
4040

4141
matches!(
4242
validated.state.claims.scope,
43-
AccessScope::TunnelEnroll | AccessScope::Wildcard
43+
AccessScope::AgentEnroll | AccessScope::Wildcard
4444
)
4545
}
4646

@@ -96,7 +96,7 @@ pub fn make_router<S>(state: DgwState) -> Router<S> {
9696
/// Enroll a new agent.
9797
///
9898
/// Requires a Bearer token that is either:
99-
/// - a JWT signed by the configured provisioner key with `TunnelEnroll` /
99+
/// - a JWT signed by the configured provisioner key with `AgentEnroll` /
100100
/// `Wildcard` scope (issued by DVLS — the only authority for agent
101101
/// enrollment tokens), or
102102
/// - the static `enrollment_secret` from the gateway configuration (admin
@@ -138,7 +138,7 @@ async fn enroll_agent(
138138
.ok_or_else(|| HttpError::not_found().msg("agent enrollment is not configured"))?;
139139

140140
// Token validation order:
141-
// 1. JWT signed by the configured provisioner key (scope == TunnelEnroll)
141+
// 1. JWT signed by the configured provisioner key (scope == AgentEnroll)
142142
// 2. Static enrollment secret from configuration (constant-time comparison)
143143
let jwt_valid = validate_enrollment_jwt(provided_token, &conf.provisioner_public_key);
144144

@@ -282,7 +282,7 @@ mod tests {
282282
let (priv_key, pub_key) = keypair();
283283
let token = sign(
284284
json!({
285-
"scope": "gateway.tunnel.enroll",
285+
"scope": "gateway.agent.enroll",
286286
"nbf": now_ts() - 60,
287287
"exp": now_ts() + 3600,
288288
"jti": Uuid::new_v4(),
@@ -334,7 +334,7 @@ mod tests {
334334
let (priv_key, pub_key) = keypair();
335335
let token = sign(
336336
json!({
337-
"scope": "gateway.tunnel.enroll",
337+
"scope": "gateway.agent.enroll",
338338
"nbf": now_ts() - 7200,
339339
"exp": now_ts() - 3600,
340340
"jti": Uuid::new_v4(),
@@ -352,7 +352,7 @@ mod tests {
352352
let (_, gateway_pub) = keypair();
353353
let token = sign(
354354
json!({
355-
"scope": "gateway.tunnel.enroll",
355+
"scope": "gateway.agent.enroll",
356356
"nbf": now_ts() - 60,
357357
"exp": now_ts() + 3600,
358358
"jti": Uuid::new_v4(),
@@ -369,7 +369,7 @@ mod tests {
369369
let (priv_key, pub_key) = keypair();
370370
let token = sign(
371371
json!({
372-
"scope": "gateway.tunnel.enroll",
372+
"scope": "gateway.agent.enroll",
373373
"nbf": now_ts() - 60,
374374
"exp": now_ts() + 3600,
375375
"jti": Uuid::new_v4(),

devolutions-gateway/src/token.rs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -472,8 +472,6 @@ pub enum AccessScope {
472472
NetMonitorConfig,
473473
#[serde(rename = "gateway.net.monitor.drain")]
474474
NetMonitorDrain,
475-
#[serde(rename = "gateway.tunnel.enroll")]
476-
TunnelEnroll,
477475
#[serde(rename = "gateway.agent.enroll")]
478476
AgentEnroll,
479477
#[serde(rename = "gateway.agent.read")]
@@ -503,7 +501,7 @@ pub struct ScopeTokenClaims {
503501
/// and expiry against the configured provisioner key.
504502
#[derive(Debug, Clone, Serialize, Deserialize)]
505503
pub struct EnrollmentTokenClaims {
506-
/// Must be `AccessScope::TunnelEnroll` (or `Wildcard`).
504+
/// Must be `AccessScope::AgentEnroll` (or `Wildcard`).
507505
pub scope: AccessScope,
508506

509507
/// JWT expiration time claim.

utils/dotnet/Devolutions.Gateway.Utils/Devolutions.Gateway.Utils.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
<Description>Useful classes to use Devolutions Gateway</Description>
1212
<Copyright>© Devolutions Inc. All rights reserved.</Copyright>
1313
<RootNamespace>Devolutions.Gateway.Utils</RootNamespace>
14-
<Version>2025.10.2</Version>
14+
<Version>2025.10.3</Version>
1515
<PackageLicenseExpression>MIT OR Apache-2.0</PackageLicenseExpression>
1616
<RepositoryUrl>https://github.com/Devolutions/devolutions-gateway.git</RepositoryUrl>
1717
<RepositoryType>git</RepositoryType>
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
using System.Text.Json.Serialization;
2+
3+
namespace Devolutions.Gateway.Utils;
4+
5+
/// <summary>
6+
/// Claims carried by an agent-tunnel enrollment JWT.
7+
///
8+
/// DVLS signs this with the gateway's provisioner private key and hands the
9+
/// resulting JWT to the operator. The operator pastes the JWT into
10+
/// <c>devolutions-agent up --enrollment-string &lt;jwt&gt;</c>; the agent uses
11+
/// it as the Bearer token on <c>POST /jet/tunnel/enroll</c>, where the
12+
/// gateway verifies the signature and the <c>scope</c> claim.
13+
///
14+
/// Use <see cref="AccessScope.GatewayAgentEnroll"/> for <see cref="Scope"/>.
15+
/// The agent reads <see cref="JetGwUrl"/>, <see cref="JetAgentName"/>, and
16+
/// <see cref="JetQuicEndpoint"/> locally without verifying the signature
17+
/// (it is the intended recipient).
18+
/// </summary>
19+
public class EnrollmentClaims : IGatewayClaims
20+
{
21+
[JsonPropertyName("scope")]
22+
public AccessScope Scope { get; set; }
23+
24+
/// <summary>Gateway HTTP base URL the agent calls for enrollment.</summary>
25+
[JsonPropertyName("jet_gw_url")]
26+
public string JetGwUrl { get; set; }
27+
28+
/// <summary>Optional agent display-name hint.</summary>
29+
[JsonPropertyName("jet_agent_name")]
30+
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
31+
public string? JetAgentName { get; set; }
32+
33+
/// <summary>
34+
/// Optional QUIC endpoint (<c>host:port</c>) the agent dials after enrollment.
35+
/// The gateway never reports this itself; the operator (DVLS) supplies it
36+
/// because only they know the agent-reachable address.
37+
/// </summary>
38+
[JsonPropertyName("jet_quic_endpoint")]
39+
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
40+
public string? JetQuicEndpoint { get; set; }
41+
42+
public EnrollmentClaims(string jetGwUrl, string? jetAgentName = null, string? jetQuicEndpoint = null)
43+
{
44+
this.Scope = AccessScope.GatewayAgentEnroll;
45+
this.JetGwUrl = jetGwUrl;
46+
this.JetAgentName = jetAgentName;
47+
this.JetQuicEndpoint = jetQuicEndpoint;
48+
}
49+
50+
public string GetContentType()
51+
{
52+
return "ENROLLMENT";
53+
}
54+
}

0 commit comments

Comments
 (0)