diff --git a/Moonlight.Api/Database/Entities/ApiKey.cs b/Moonlight.Api/Database/Entities/ApiKey.cs
index 5bbbd4f6..3073f0c6 100644
--- a/Moonlight.Api/Database/Entities/ApiKey.cs
+++ b/Moonlight.Api/Database/Entities/ApiKey.cs
@@ -14,6 +14,7 @@ public class ApiKey : IActionTimestamps
public required string Description { get; set; }
public string[] Permissions { get; set; } = [];
+ public DateTimeOffset ValidUntil { get; set; }
[MaxLength(32)]
public string Key { get; set; }
diff --git a/Moonlight.Api/Database/Migrations/20260209114238_AddedValidUntilToApiKeys.Designer.cs b/Moonlight.Api/Database/Migrations/20260209114238_AddedValidUntilToApiKeys.Designer.cs
new file mode 100644
index 00000000..03bdc39f
--- /dev/null
+++ b/Moonlight.Api/Database/Migrations/20260209114238_AddedValidUntilToApiKeys.Designer.cs
@@ -0,0 +1,254 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Moonlight.Api.Database;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace Moonlight.Api.Database.Migrations
+{
+ [DbContext(typeof(DataContext))]
+ [Migration("20260209114238_AddedValidUntilToApiKeys")]
+ partial class AddedValidUntilToApiKeys
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("core")
+ .HasAnnotation("ProductVersion", "10.0.1")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Moonlight.Api.Database.Entities.ApiKey", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)");
+
+ b.Property("Key")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.PrimitiveCollection("Permissions")
+ .IsRequired()
+ .HasColumnType("text[]");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ValidUntil")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.ToTable("ApiKeys", "core");
+ });
+
+ modelBuilder.Entity("Moonlight.Api.Database.Entities.Role", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.PrimitiveCollection("Permissions")
+ .IsRequired()
+ .HasColumnType("text[]");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.ToTable("Roles", "core");
+ });
+
+ modelBuilder.Entity("Moonlight.Api.Database.Entities.RoleMember", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("RoleId")
+ .HasColumnType("integer");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RoleId");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("RoleMembers", "core");
+ });
+
+ modelBuilder.Entity("Moonlight.Api.Database.Entities.SettingsOption", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Key")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("ValueJson")
+ .IsRequired()
+ .HasMaxLength(4096)
+ .HasColumnType("jsonb");
+
+ b.HasKey("Id");
+
+ b.ToTable("SettingsOptions", "core");
+ });
+
+ modelBuilder.Entity("Moonlight.Api.Database.Entities.Theme", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Author")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.Property("CssContent")
+ .IsRequired()
+ .HasMaxLength(20000)
+ .HasColumnType("character varying(20000)");
+
+ b.Property("IsEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.Property("Version")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.HasKey("Id");
+
+ b.ToTable("Themes", "core");
+ });
+
+ modelBuilder.Entity("Moonlight.Api.Database.Entities.User", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Email")
+ .IsRequired()
+ .HasMaxLength(254)
+ .HasColumnType("character varying(254)");
+
+ b.Property("InvalidateTimestamp")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Username")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.HasKey("Id");
+
+ b.ToTable("Users", "core");
+ });
+
+ modelBuilder.Entity("Moonlight.Api.Database.Entities.RoleMember", b =>
+ {
+ b.HasOne("Moonlight.Api.Database.Entities.Role", "Role")
+ .WithMany("Members")
+ .HasForeignKey("RoleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Moonlight.Api.Database.Entities.User", "User")
+ .WithMany("RoleMemberships")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Role");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("Moonlight.Api.Database.Entities.Role", b =>
+ {
+ b.Navigation("Members");
+ });
+
+ modelBuilder.Entity("Moonlight.Api.Database.Entities.User", b =>
+ {
+ b.Navigation("RoleMemberships");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/Moonlight.Api/Database/Migrations/20260209114238_AddedValidUntilToApiKeys.cs b/Moonlight.Api/Database/Migrations/20260209114238_AddedValidUntilToApiKeys.cs
new file mode 100644
index 00000000..dc7f1857
--- /dev/null
+++ b/Moonlight.Api/Database/Migrations/20260209114238_AddedValidUntilToApiKeys.cs
@@ -0,0 +1,32 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Moonlight.Api.Database.Migrations
+{
+ ///
+ public partial class AddedValidUntilToApiKeys : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "ValidUntil",
+ schema: "core",
+ table: "ApiKeys",
+ type: "timestamp with time zone",
+ nullable: false,
+ defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "ValidUntil",
+ schema: "core",
+ table: "ApiKeys");
+ }
+ }
+}
diff --git a/Moonlight.Api/Database/Migrations/DataContextModelSnapshot.cs b/Moonlight.Api/Database/Migrations/DataContextModelSnapshot.cs
index 53294dcf..2945a90c 100644
--- a/Moonlight.Api/Database/Migrations/DataContextModelSnapshot.cs
+++ b/Moonlight.Api/Database/Migrations/DataContextModelSnapshot.cs
@@ -56,6 +56,9 @@ namespace Moonlight.Api.Database.Migrations
b.Property("UpdatedAt")
.HasColumnType("timestamp with time zone");
+ b.Property("ValidUntil")
+ .HasColumnType("timestamp with time zone");
+
b.HasKey("Id");
b.ToTable("ApiKeys", "core");
diff --git a/Moonlight.Api/Implementations/ApiKeyScheme/ApiKeySchemeHandler.cs b/Moonlight.Api/Implementations/ApiKeyScheme/ApiKeySchemeHandler.cs
index 9292a5ab..dcc9a483 100644
--- a/Moonlight.Api/Implementations/ApiKeyScheme/ApiKeySchemeHandler.cs
+++ b/Moonlight.Api/Implementations/ApiKeyScheme/ApiKeySchemeHandler.cs
@@ -16,6 +16,8 @@ public class ApiKeySchemeHandler : AuthenticationHandler
private readonly DatabaseRepository ApiKeyRepository;
private readonly IMemoryCache MemoryCache;
+ private const string CacheKeyFormat = $"Moonlight.Api.{nameof(ApiKeySchemeHandler)}.{{0}}";
+
public ApiKeySchemeHandler(
IOptionsMonitor options,
ILoggerFactory logger,
@@ -38,18 +40,20 @@ public class ApiKeySchemeHandler : AuthenticationHandler
if (authHeaderValue.Length > 32)
return AuthenticateResult.Fail("Invalid api key specified");
- if (!MemoryCache.TryGetValue(authHeaderValue, out var apiKey))
+ var cacheKey = string.Format(CacheKeyFormat, authHeaderValue);
+
+ if (!MemoryCache.TryGetValue(cacheKey, out var apiKey))
{
apiKey = await ApiKeyRepository
.Query()
.Where(x => x.Key == authHeaderValue)
- .Select(x => new ApiKeySession(x.Permissions))
+ .Select(x => new ApiKeySession(x.Permissions, x.ValidUntil))
.FirstOrDefaultAsync();
if (apiKey == null)
return AuthenticateResult.Fail("Invalid api key specified");
- MemoryCache.Set(authHeaderValue, apiKey, Options.LookupCacheTime);
+ MemoryCache.Set(cacheKey, apiKey, Options.LookupCacheTime);
}
else
{
@@ -57,6 +61,9 @@ public class ApiKeySchemeHandler : AuthenticationHandler
return AuthenticateResult.Fail("Invalid api key specified");
}
+ if (DateTimeOffset.UtcNow > apiKey.ValidUntil)
+ return AuthenticateResult.Fail("Api key expired");
+
return AuthenticateResult.Success(new AuthenticationTicket(
new ClaimsPrincipal(
new ClaimsIdentity(
@@ -67,5 +74,5 @@ public class ApiKeySchemeHandler : AuthenticationHandler
));
}
- private record ApiKeySession(string[] Permissions);
+ private record ApiKeySession(string[] Permissions, DateTimeOffset ValidUntil);
}
\ No newline at end of file
diff --git a/Moonlight.Frontend/UI/Admin/Modals/CreateApiKeyDialog.razor b/Moonlight.Frontend/UI/Admin/Modals/CreateApiKeyDialog.razor
index 459164c5..37f2b36f 100644
--- a/Moonlight.Frontend/UI/Admin/Modals/CreateApiKeyDialog.razor
+++ b/Moonlight.Frontend/UI/Admin/Modals/CreateApiKeyDialog.razor
@@ -35,6 +35,10 @@
Description
+
+ Valid until
+
+
Permissions
@@ -60,13 +64,15 @@
{
Request = new()
{
- Permissions = []
+ Permissions = [],
+ ValidUntil = DateTimeOffset.UtcNow
};
}
private async Task OnSubmitAsync(EditContext editContext, ValidationMessageStore validationMessageStore)
{
Request.Permissions = Permissions.ToArray();
+ Request.ValidUntil = Request.ValidUntil.ToUniversalTime();
var response = await HttpClient.PostAsJsonAsync(
"/api/admin/apiKeys",
diff --git a/Moonlight.Frontend/UI/Admin/Modals/UpdateApiKeyDialog.razor b/Moonlight.Frontend/UI/Admin/Modals/UpdateApiKeyDialog.razor
index f9e4aef9..75c75115 100644
--- a/Moonlight.Frontend/UI/Admin/Modals/UpdateApiKeyDialog.razor
+++ b/Moonlight.Frontend/UI/Admin/Modals/UpdateApiKeyDialog.razor
@@ -35,6 +35,10 @@
Description
+
+ Valid until
+
+
Permissions
@@ -65,6 +69,7 @@
private async Task OnSubmitAsync(EditContext editContext, ValidationMessageStore validationMessageStore)
{
Request.Permissions = Permissions.ToArray();
+ Request.ValidUntil = Request.ValidUntil.ToUniversalTime();
var response = await HttpClient.PatchAsJsonAsync(
$"/api/admin/apiKeys/{Key.Id}",
diff --git a/Moonlight.Frontend/UI/Admin/Views/Sys/ApiKeys.razor b/Moonlight.Frontend/UI/Admin/Views/Sys/ApiKeys.razor
index 9b76f917..a4cfda72 100644
--- a/Moonlight.Frontend/UI/Admin/Views/Sys/ApiKeys.razor
+++ b/Moonlight.Frontend/UI/Admin/Views/Sys/ApiKeys.razor
@@ -48,7 +48,21 @@
+ Identifier="@nameof(ApiKeyDto.Description)" Field="k => k.Description" HeadClassName="hidden lg:table-cell" CellClassName="hidden lg:table-cell" />
+
+
+
+ @{
+ var diff = context.ValidUntil - DateTimeOffset.UtcNow;
+ var text = string.Format(diff.TotalSeconds < 0 ? "Expired since {0}" : "Expires in {0}", Formatter.FormatDuration(diff));
+ }
+
+
+ @text
+
+
+
+
diff --git a/Moonlight.Shared/Http/Requests/Admin/ApiKeys/CreateApiKeyDto.cs b/Moonlight.Shared/Http/Requests/Admin/ApiKeys/CreateApiKeyDto.cs
index 2b60a525..1b58d832 100644
--- a/Moonlight.Shared/Http/Requests/Admin/ApiKeys/CreateApiKeyDto.cs
+++ b/Moonlight.Shared/Http/Requests/Admin/ApiKeys/CreateApiKeyDto.cs
@@ -4,13 +4,9 @@ namespace Moonlight.Shared.Http.Requests.Admin.ApiKeys;
public class CreateApiKeyDto
{
- [Required]
- [MaxLength(30)]
- public string Name { get; set; }
-
+ [Required] [MaxLength(30)] public string Name { get; set; }
[MaxLength(300)] public string Description { get; set; } = "";
-
-
- [Required]
- public string[] Permissions { get; set; }
+
+ [Required] public DateTimeOffset ValidUntil { get; set; }
+ [Required] public string[] Permissions { get; set; }
}
\ No newline at end of file
diff --git a/Moonlight.Shared/Http/Requests/Admin/ApiKeys/UpdateApiKeyDto.cs b/Moonlight.Shared/Http/Requests/Admin/ApiKeys/UpdateApiKeyDto.cs
index 3c154ec7..1044904c 100644
--- a/Moonlight.Shared/Http/Requests/Admin/ApiKeys/UpdateApiKeyDto.cs
+++ b/Moonlight.Shared/Http/Requests/Admin/ApiKeys/UpdateApiKeyDto.cs
@@ -4,12 +4,9 @@ namespace Moonlight.Shared.Http.Requests.Admin.ApiKeys;
public class UpdateApiKeyDto
{
- [Required]
- [MaxLength(30)]
- public string Name { get; set; }
-
+ [Required] [MaxLength(30)] public string Name { get; set; }
[MaxLength(300)] public string Description { get; set; } = "";
-
- [Required]
- public string[] Permissions { get; set; }
+
+ [Required] public DateTimeOffset ValidUntil { get; set; }
+ [Required] public string[] Permissions { get; set; }
}
\ No newline at end of file
diff --git a/Moonlight.Shared/Http/Responses/Admin/ApiKeys/ApiKeyDto.cs b/Moonlight.Shared/Http/Responses/Admin/ApiKeys/ApiKeyDto.cs
index 85158f42..c3129546 100644
--- a/Moonlight.Shared/Http/Responses/Admin/ApiKeys/ApiKeyDto.cs
+++ b/Moonlight.Shared/Http/Responses/Admin/ApiKeys/ApiKeyDto.cs
@@ -1,3 +1,3 @@
namespace Moonlight.Shared.Http.Responses.Admin.ApiKeys;
-public record ApiKeyDto(int Id, string Name, string Description, string[] Permissions, string Key, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt);
\ No newline at end of file
+public record ApiKeyDto(int Id, string Name, string Description, string[] Permissions, DateTimeOffset ValidUntil, string Key, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt);
\ No newline at end of file