Skip to content

Commit cfdb050

Browse files
add JWT
1 parent 0d07d1e commit cfdb050

5 files changed

Lines changed: 250 additions & 0 deletions

File tree

ApiController.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
using GithubActionsOrchestrator.Auth;
12
using GithubActionsOrchestrator.Database;
23
using GithubActionsOrchestrator.Models;
4+
using Microsoft.AspNetCore.Authorization;
35
using Microsoft.AspNetCore.Mvc;
46
using Microsoft.EntityFrameworkCore;
57

@@ -341,6 +343,7 @@ public async Task<IResult> GetFilterOptions()
341343

342344
[Route("runners/{runnerId}/delete")]
343345
[HttpPost]
346+
[Authorize(Policy = TeleportAuth.CanMutatePolicy)]
344347
public async Task<IResult> DeleteRunner(int runnerId)
345348
{
346349
await using var db = new ActionsRunnerContext();
@@ -373,6 +376,7 @@ public record CreateRunnerRequest(string Owner, string Size, string? Profile, st
373376

374377
[Route("runners")]
375378
[HttpPost]
379+
[Authorize(Policy = TeleportAuth.CanMutatePolicy)]
376380
public async Task<IResult> CreateRunner([FromBody] CreateRunnerRequest request)
377381
{
378382
if (request == null || string.IsNullOrWhiteSpace(request.Owner) || string.IsNullOrWhiteSpace(request.Size))
@@ -403,6 +407,7 @@ public async Task<IResult> CreateRunner([FromBody] CreateRunnerRequest request)
403407

404408
[Route("jobs/{jobId}/reschedule")]
405409
[HttpPost]
410+
[Authorize(Policy = TeleportAuth.CanMutatePolicy)]
406411
public async Task<IResult> RescheduleJob(int jobId)
407412
{
408413
await using var db = new ActionsRunnerContext();
@@ -439,6 +444,7 @@ public async Task<IResult> RescheduleJob(int jobId)
439444

440445
[Route("jobs/{jobId}/cancel")]
441446
[HttpPost]
447+
[Authorize(Policy = TeleportAuth.CanMutatePolicy)]
442448
public async Task<IResult> CancelJob(int jobId)
443449
{
444450
await using var db = new ActionsRunnerContext();
@@ -489,6 +495,7 @@ public async Task<IResult> CancelJob(int jobId)
489495

490496
[Route("cleanup-stuck-creation")]
491497
[HttpPost]
498+
[Authorize(Policy = TeleportAuth.CanMutatePolicy)]
492499
public async Task<IResult> CleanupStuckCreation([FromQuery] int olderThanMinutes = 15)
493500
{
494501
olderThanMinutes = Math.Max(0, olderThanMinutes);
@@ -602,6 +609,7 @@ public async Task<IResult> GetProvisionScript(string provisionId, [FromHeader(Na
602609

603610
[Route("kill-non-processing-runners")]
604611
[HttpPost]
612+
[Authorize(Policy = TeleportAuth.CanMutatePolicy)]
605613
public async Task<IResult> KillNonProcessingRunners([FromQuery] string cloud = null)
606614
{
607615
await using var db = new ActionsRunnerContext();
@@ -650,6 +658,7 @@ public async Task<IResult> KillNonProcessingRunners([FromQuery] string cloud = n
650658

651659
[Route("clear-creation-queue")]
652660
[HttpPost]
661+
[Authorize(Policy = TeleportAuth.CanMutatePolicy)]
653662
public async Task<IResult> ClearCreationQueue()
654663
{
655664
await using var db = new ActionsRunnerContext();
@@ -668,4 +677,22 @@ public IResult Health()
668677
{
669678
return Results.Ok(new { status = "healthy" });
670679
}
680+
681+
// Identity of the caller (from the verified Teleport JWT, if present) plus whether
682+
// they may run mutating actions. The frontend uses this to gate its action buttons.
683+
[Route("me")]
684+
[HttpGet]
685+
public IResult Me()
686+
{
687+
var cfg = Program.Config.TeleportAuth;
688+
bool authenticated = User?.Identity?.IsAuthenticated == true;
689+
return Results.Json(new
690+
{
691+
authEnabled = cfg.Enabled,
692+
authenticated,
693+
user = authenticated ? TeleportAuth.GetUsername(User) : null,
694+
roles = authenticated ? TeleportAuth.GetRoles(User).Distinct().ToArray() : Array.Empty<string>(),
695+
canMutate = !cfg.Enabled || TeleportAuth.IsAuthorizedToMutate(User, cfg),
696+
});
697+
}
671698
}

Auth/TeleportAuth.cs

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
using System.Security.Claims;
2+
using GithubActionsOrchestrator.Models;
3+
using Microsoft.AspNetCore.Authentication.JwtBearer;
4+
using Microsoft.IdentityModel.Tokens;
5+
6+
namespace GithubActionsOrchestrator.Auth;
7+
8+
public static class TeleportAuth
9+
{
10+
public const string CanMutatePolicy = "CanMutate";
11+
public const string JwtHeader = "Teleport-Jwt-Assertion";
12+
13+
public static void AddTeleportAuth(this WebApplicationBuilder builder, TeleportAuthConfiguration cfg)
14+
{
15+
builder.Services.AddHttpClient();
16+
builder.Services.AddSingleton<TeleportSigningKeyProvider>();
17+
18+
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
19+
.AddJwtBearer();
20+
21+
// Configure JwtBearer with DI access to the key provider.
22+
builder.Services.AddOptions<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme)
23+
.Configure<TeleportSigningKeyProvider>((options, keyProvider) =>
24+
{
25+
options.MapInboundClaims = false; // keep raw Teleport claim names ("roles", "sub", "username")
26+
options.RequireHttpsMetadata = false; // we supply keys ourselves, no metadata endpoint
27+
options.Events = new JwtBearerEvents
28+
{
29+
// Teleport passes the assertion in a custom header, not "Authorization: Bearer".
30+
OnMessageReceived = ctx =>
31+
{
32+
var header = ctx.Request.Headers[JwtHeader].FirstOrDefault();
33+
if (!string.IsNullOrEmpty(header))
34+
ctx.Token = header;
35+
return Task.CompletedTask;
36+
}
37+
};
38+
options.TokenValidationParameters = new TokenValidationParameters
39+
{
40+
ValidateIssuer = !string.IsNullOrWhiteSpace(cfg.Issuer),
41+
ValidIssuer = cfg.Issuer,
42+
ValidateAudience = !string.IsNullOrWhiteSpace(cfg.Audience),
43+
ValidAudience = cfg.Audience,
44+
ValidateLifetime = true,
45+
ValidateIssuerSigningKey = true,
46+
NameClaimType = "username",
47+
RoleClaimType = "roles",
48+
ClockSkew = TimeSpan.FromMinutes(1),
49+
IssuerSigningKeyResolver = (_, _, kid, _) => keyProvider.ResolveKeys(kid),
50+
};
51+
});
52+
53+
builder.Services.AddAuthorizationBuilder()
54+
.AddPolicy(CanMutatePolicy, policy =>
55+
{
56+
if (!cfg.Enabled)
57+
{
58+
// Auth disabled: fall back to the API key gate only.
59+
policy.RequireAssertion(_ => true);
60+
return;
61+
}
62+
63+
policy.RequireAuthenticatedUser();
64+
policy.RequireAssertion(ctx => IsAuthorizedToMutate(ctx.User, cfg));
65+
});
66+
}
67+
68+
/// <summary>
69+
/// A user may mutate when no allowlists are configured (any authenticated user), or when
70+
/// they hold an allowed role, or when their username is explicitly allowed.
71+
/// </summary>
72+
public static bool IsAuthorizedToMutate(ClaimsPrincipal user, TeleportAuthConfiguration cfg)
73+
{
74+
if (user?.Identity?.IsAuthenticated != true)
75+
return false;
76+
77+
var roles = cfg.AuthorizedRoles ?? new List<string>();
78+
var users = cfg.AuthorizedUsers ?? new List<string>();
79+
80+
if (roles.Count == 0 && users.Count == 0)
81+
return true;
82+
83+
bool hasAllowedRole = roles.Count > 0 && GetRoles(user).Any(r => roles.Contains(r, StringComparer.OrdinalIgnoreCase));
84+
bool isAllowedUser = users.Count > 0 && users.Contains(GetUsername(user), StringComparer.OrdinalIgnoreCase);
85+
86+
return hasAllowedRole || isAllowedUser;
87+
}
88+
89+
public static string GetUsername(ClaimsPrincipal user)
90+
{
91+
return user?.FindFirst("username")?.Value
92+
?? user?.FindFirst("sub")?.Value
93+
?? user?.Identity?.Name;
94+
}
95+
96+
public static IEnumerable<string> GetRoles(ClaimsPrincipal user)
97+
{
98+
if (user == null) return Enumerable.Empty<string>();
99+
return user.Claims
100+
.Where(c => c.Type is "roles" or ClaimTypes.Role)
101+
.Select(c => c.Value);
102+
}
103+
}
104+
105+
/// <summary>
106+
/// Fetches and caches Teleport's JWKS signing keys, refreshing on an interval and on a
107+
/// key-id miss (so key rotation is picked up without a restart).
108+
/// </summary>
109+
public class TeleportSigningKeyProvider
110+
{
111+
private readonly IHttpClientFactory _httpClientFactory;
112+
private readonly ILogger<TeleportSigningKeyProvider> _logger;
113+
private readonly string _jwksUrl;
114+
private readonly TimeSpan _minRefreshInterval = TimeSpan.FromMinutes(5);
115+
private readonly object _lock = new();
116+
117+
private IList<SecurityKey> _keys = new List<SecurityKey>();
118+
private DateTime _lastRefreshUtc = DateTime.MinValue;
119+
120+
public TeleportSigningKeyProvider(IHttpClientFactory httpClientFactory, ILogger<TeleportSigningKeyProvider> logger)
121+
{
122+
_httpClientFactory = httpClientFactory;
123+
_logger = logger;
124+
_jwksUrl = Program.Config.TeleportAuth?.JwksUrl;
125+
}
126+
127+
public IEnumerable<SecurityKey> ResolveKeys(string kid)
128+
{
129+
var current = _keys;
130+
var match = Filter(current, kid);
131+
if (match.Count > 0)
132+
return match;
133+
134+
// Unknown kid (or empty cache): refresh once (throttled) and retry.
135+
Refresh(force: false);
136+
return Filter(_keys, kid);
137+
}
138+
139+
private static List<SecurityKey> Filter(IList<SecurityKey> keys, string kid)
140+
{
141+
if (keys == null || keys.Count == 0)
142+
return new List<SecurityKey>();
143+
if (string.IsNullOrEmpty(kid))
144+
return keys.ToList();
145+
var byKid = keys.Where(k => string.Equals(k.KeyId, kid, StringComparison.Ordinal)).ToList();
146+
// If the token has a kid we don't recognise, fall back to trying all keys.
147+
return byKid.Count > 0 ? byKid : keys.ToList();
148+
}
149+
150+
private void Refresh(bool force)
151+
{
152+
if (string.IsNullOrWhiteSpace(_jwksUrl))
153+
return;
154+
155+
lock (_lock)
156+
{
157+
if (!force && DateTime.UtcNow - _lastRefreshUtc < _minRefreshInterval)
158+
return;
159+
160+
try
161+
{
162+
var client = _httpClientFactory.CreateClient();
163+
client.Timeout = TimeSpan.FromSeconds(10);
164+
var json = client.GetStringAsync(_jwksUrl).GetAwaiter().GetResult();
165+
var keySet = new JsonWebKeySet(json);
166+
var keys = keySet.GetSigningKeys();
167+
_keys = keys;
168+
_lastRefreshUtc = DateTime.UtcNow;
169+
_logger.LogInformation("Refreshed Teleport JWKS: {Count} signing key(s) from {Url}", keys.Count, _jwksUrl);
170+
}
171+
catch (Exception ex)
172+
{
173+
_lastRefreshUtc = DateTime.UtcNow; // avoid hammering a broken endpoint
174+
_logger.LogError(ex, "Failed to fetch Teleport JWKS from {Url}", _jwksUrl);
175+
}
176+
}
177+
}
178+
}

GithubActionsOrchestrator.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
</PropertyGroup>
1010

1111
<ItemGroup>
12+
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
1213
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.9" />
1314
<ProjectReference Include="..\HetznerCloud.API\HetznerCloudApi\HetznerCloudApi.csproj" />
1415
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">

Models/AutoScalerConfiguration.cs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,4 +68,37 @@ public class AutoScalerConfiguration
6868
public string DigitalOceanVpcUuid { get; set; }
6969

7070
public string DigitalOceanDefaultImage { get; set; } = "ubuntu-24-04-x64";
71+
72+
/// <summary>
73+
/// Teleport-based authorization for mutating dashboard API calls. When disabled,
74+
/// mutating endpoints fall back to the API key only.
75+
/// </summary>
76+
public TeleportAuthConfiguration TeleportAuth { get; set; } = new();
77+
}
78+
79+
public class TeleportAuthConfiguration
80+
{
81+
/// <summary>Enable JWT verification + role/user authorization on mutating endpoints.</summary>
82+
public bool Enabled { get; set; } = false;
83+
84+
/// <summary>URL of the Teleport cluster JWKS (e.g. https://teleport.example.com/.well-known/jwks.json).</summary>
85+
[JsonConverter(typeof(EnvironmentAwareJsonConverter<string>))]
86+
public string JwksUrl { get; set; }
87+
88+
/// <summary>Expected token issuer (the Teleport proxy address). Leave empty to skip issuer validation.</summary>
89+
[JsonConverter(typeof(EnvironmentAwareJsonConverter<string>))]
90+
public string Issuer { get; set; }
91+
92+
/// <summary>
93+
/// Expected audience — the public address of the Teleport app that fronts the viewer
94+
/// (Teleport sets the JWT "aud" to this). Leave empty to skip audience validation.
95+
/// </summary>
96+
[JsonConverter(typeof(EnvironmentAwareJsonConverter<string>))]
97+
public string Audience { get; set; }
98+
99+
/// <summary>Teleport roles allowed to run mutating actions. Empty = role membership not required.</summary>
100+
public List<string> AuthorizedRoles { get; set; } = new();
101+
102+
/// <summary>Teleport usernames allowed to run mutating actions. Empty = user membership not required.</summary>
103+
public List<string> AuthorizedUsers { get; set; } = new();
71104
}

Program.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using System.Text;
44
using System.Text.Json;
55
using System.Text.Json.Serialization;
6+
using GithubActionsOrchestrator.Auth;
67
using GithubActionsOrchestrator.CloudControllers;
78
using GithubActionsOrchestrator.Database;
89
using GithubActionsOrchestrator.GitHub;
@@ -154,6 +155,13 @@ public static void Main(string[] args)
154155
options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;
155156
});
156157

158+
// Teleport JWT verification + authorization for mutating dashboard endpoints.
159+
builder.AddTeleportAuth(Config.TeleportAuth);
160+
if (Config.TeleportAuth.Enabled)
161+
Log.Information("Teleport auth enabled: mutating endpoints require an authorized JWT (JWKS: {Jwks})", Config.TeleportAuth.JwksUrl);
162+
else
163+
Log.Warning("Teleport auth is DISABLED: mutating endpoints are gated by the API key only.");
164+
157165
builder.Services.AddSingleton<ICloudController, HetznerCloudController>(svc =>
158166
{
159167
ILogger<HetznerCloudController> logger = svc.GetRequiredService<ILogger<HetznerCloudController>>();
@@ -206,6 +214,9 @@ public static void Main(string[] args)
206214
await next();
207215
}));
208216

217+
app.UseAuthentication();
218+
app.UseAuthorization();
219+
209220
app.MapPost("/add-runner", AddRunnerManuallyHandler);
210221
app.MapPost("/runner-state", RunnerStateReportHandler);
211222
app.MapPost("/github-webhook", GithubWebhookHandler);

0 commit comments

Comments
 (0)