diff --git a/FictionArchive.API/FictionArchive.API.csproj b/FictionArchive.API/FictionArchive.API.csproj
index 73638d7..302e65c 100644
--- a/FictionArchive.API/FictionArchive.API.csproj
+++ b/FictionArchive.API/FictionArchive.API.csproj
@@ -13,6 +13,7 @@
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
@@ -20,6 +21,7 @@
+
diff --git a/FictionArchive.API/Program.cs b/FictionArchive.API/Program.cs
index 8ee5e37..f0608ee 100644
--- a/FictionArchive.API/Program.cs
+++ b/FictionArchive.API/Program.cs
@@ -12,7 +12,11 @@ public class Program
#region Fusion Gateway
- builder.Services.AddHttpClient("Fusion");
+ builder.Services.AddHttpClient("Fusion")
+ .AddHeaderPropagation(opt =>
+ {
+ opt.Headers.Add("Authorization");
+ });
builder.Services
.AddFusionGatewayServer()
@@ -21,23 +25,29 @@ public class Program
#endregion
+ // Add authentication
+ builder.Services.AddOidcAuthentication(builder.Configuration);
+
builder.Services.AddCors(options =>
{
- options.AddPolicy("AllowAllOrigins",
- builder =>
+ options.AddPolicy("AllowFictionArchiveOrigins",
+ policyBuilder =>
{
- builder.AllowAnyOrigin()
+ policyBuilder.WithOrigins("https://fictionarchive.orfl.xyz", "http://localhost:5173")
.AllowAnyMethod()
- .AllowAnyHeader();
+ .AllowAnyHeader()
+ .AllowCredentials();
});
});
var app = builder.Build();
- app.UseCors("AllowAllOrigins");
+ app.UseCors("AllowFictionArchiveOrigins");
app.MapHealthChecks("/healthz");
+ app.UseHeaderPropagation();
+
app.MapGraphQL();
app.RunWithGraphQLCommands(args);
diff --git a/FictionArchive.API/appsettings.json b/FictionArchive.API/appsettings.json
index 10f68b8..b581c6a 100644
--- a/FictionArchive.API/appsettings.json
+++ b/FictionArchive.API/appsettings.json
@@ -5,5 +5,15 @@
"Microsoft.AspNetCore": "Warning"
}
},
- "AllowedHosts": "*"
+ "AllowedHosts": "*",
+ "OIDC": {
+ "Authority": "https://auth.orfl.xyz/application/o/fiction-archive/",
+ "ClientId": "fictionarchive-api",
+ "Audience": "fictionarchive-api",
+ "ValidIssuer": "https://auth.orfl.xyz/application/o/fiction-archive/",
+ "ValidateIssuer": true,
+ "ValidateAudience": true,
+ "ValidateLifetime": true,
+ "ValidateIssuerSigningKey": true
+ }
}
diff --git a/FictionArchive.Service.FileService/Controllers/S3ProxyController.cs b/FictionArchive.Service.FileService/Controllers/S3ProxyController.cs
index 384095b..65a33fa 100644
--- a/FictionArchive.Service.FileService/Controllers/S3ProxyController.cs
+++ b/FictionArchive.Service.FileService/Controllers/S3ProxyController.cs
@@ -2,6 +2,7 @@ using System.Web;
using Amazon.S3;
using Amazon.S3.Model;
using FictionArchive.Service.FileService.Models;
+using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
@@ -10,6 +11,7 @@ namespace FictionArchive.Service.FileService.Controllers
{
[Route("api/{*path}")]
[ApiController]
+ [Authorize]
public class S3ProxyController : ControllerBase
{
private readonly AmazonS3Client _amazonS3Client;
diff --git a/FictionArchive.Service.FileService/FictionArchive.Service.FileService.csproj b/FictionArchive.Service.FileService/FictionArchive.Service.FileService.csproj
index 7e69402..436862b 100644
--- a/FictionArchive.Service.FileService/FictionArchive.Service.FileService.csproj
+++ b/FictionArchive.Service.FileService/FictionArchive.Service.FileService.csproj
@@ -21,6 +21,7 @@
+
diff --git a/FictionArchive.Service.FileService/Program.cs b/FictionArchive.Service.FileService/Program.cs
index 3decc95..7d8ac6d 100644
--- a/FictionArchive.Service.FileService/Program.cs
+++ b/FictionArchive.Service.FileService/Program.cs
@@ -34,6 +34,10 @@ public class Program
#endregion
+ // Add authentication with cookie support
+ builder.Services.AddOidcCookieAuthentication(builder.Configuration);
+ builder.Services.AddFictionArchiveAuthorization();
+
builder.Services.Configure(builder.Configuration.GetSection("ProxyConfiguration"));
// Add S3 Client
@@ -60,6 +64,9 @@ public class Program
app.UseSwaggerUI();
}
+ app.UseAuthentication();
+ app.UseAuthorization();
+
app.MapHealthChecks("/healthz");
app.MapControllers();
diff --git a/FictionArchive.Service.FileService/appsettings.json b/FictionArchive.Service.FileService/appsettings.json
index 0f6f7a4..ffe7eae 100644
--- a/FictionArchive.Service.FileService/appsettings.json
+++ b/FictionArchive.Service.FileService/appsettings.json
@@ -9,7 +9,7 @@
"BaseUrl": "https://localhost:7247/api"
},
"RabbitMQ": {
- "ConnectionString": "amqp://localhost2",
+ "ConnectionString": "amqp://localhost",
"ClientIdentifier": "FileService"
},
"S3": {
@@ -18,5 +18,15 @@
"AccessKey": "REPLACE_ME",
"SecretKey": "REPLACE_ME"
},
+ "OIDC": {
+ "Authority": "https://auth.orfl.xyz/application/o/fiction-archive/",
+ "ClientId": "fictionarchive-files",
+ "Audience": "fictionarchive-api",
+ "ValidIssuer": "https://auth.orfl.xyz/application/o/fiction-archive/",
+ "ValidateIssuer": true,
+ "ValidateAudience": true,
+ "ValidateLifetime": true,
+ "ValidateIssuerSigningKey": true
+ },
"AllowedHosts": "*"
}
diff --git a/FictionArchive.Service.NovelService/GraphQL/Mutation.cs b/FictionArchive.Service.NovelService/GraphQL/Mutation.cs
index 15f92a7..af7724f 100644
--- a/FictionArchive.Service.NovelService/GraphQL/Mutation.cs
+++ b/FictionArchive.Service.NovelService/GraphQL/Mutation.cs
@@ -6,17 +6,20 @@ using FictionArchive.Service.NovelService.Models.SourceAdapters;
using FictionArchive.Service.NovelService.Services;
using FictionArchive.Service.NovelService.Services.SourceAdapters;
using FictionArchive.Service.Shared.Services.EventBus;
+using HotChocolate.Authorization;
using Microsoft.EntityFrameworkCore;
namespace FictionArchive.Service.NovelService.GraphQL;
public class Mutation
{
+ [Authorize]
public async Task ImportNovel(string novelUrl, NovelUpdateService service)
{
return await service.QueueNovelImport(novelUrl);
}
+ [Authorize]
public async Task FetchChapterContents(uint novelId,
uint chapterNumber,
NovelUpdateService service)
diff --git a/FictionArchive.Service.NovelService/GraphQL/Query.cs b/FictionArchive.Service.NovelService/GraphQL/Query.cs
index 4539cfc..c594e2f 100644
--- a/FictionArchive.Service.NovelService/GraphQL/Query.cs
+++ b/FictionArchive.Service.NovelService/GraphQL/Query.cs
@@ -1,5 +1,6 @@
using FictionArchive.Service.NovelService.Models.Novels;
using FictionArchive.Service.NovelService.Services;
+using HotChocolate.Authorization;
using HotChocolate.Data;
using HotChocolate.Types;
@@ -7,6 +8,7 @@ namespace FictionArchive.Service.NovelService.GraphQL;
public class Query
{
+ [Authorize]
[UsePaging]
[UseProjection]
[UseFiltering]
diff --git a/FictionArchive.Service.NovelService/Program.cs b/FictionArchive.Service.NovelService/Program.cs
index ff9fd26..0b1f860 100644
--- a/FictionArchive.Service.NovelService/Program.cs
+++ b/FictionArchive.Service.NovelService/Program.cs
@@ -43,7 +43,8 @@ public class Program
#region GraphQL
- builder.Services.AddDefaultGraphQl();
+ builder.Services.AddDefaultGraphQl()
+ .AddAuthorization();
#endregion
@@ -75,6 +76,10 @@ public class Program
builder.Services.AddHealthChecks();
+ // Authentication & Authorization
+ builder.Services.AddOidcAuthentication(builder.Configuration);
+ builder.Services.AddFictionArchiveAuthorization();
+
var app = builder.Build();
// Update database (skip in schema export mode)
@@ -88,7 +93,10 @@ public class Program
app.UseHttpsRedirection();
app.MapHealthChecks("/healthz");
-
+
+ app.UseAuthentication();
+ app.UseAuthorization();
+
app.MapGraphQL();
app.RunWithGraphQLCommands(args);
diff --git a/FictionArchive.Service.NovelService/appsettings.json b/FictionArchive.Service.NovelService/appsettings.json
index 3bf5160..f287976 100644
--- a/FictionArchive.Service.NovelService/appsettings.json
+++ b/FictionArchive.Service.NovelService/appsettings.json
@@ -19,5 +19,15 @@
"ConnectionString": "amqp://localhost",
"ClientIdentifier": "NovelService"
},
- "AllowedHosts": "*"
+ "AllowedHosts": "*",
+ "OIDC": {
+ "Authority": "https://auth.orfl.xyz/application/o/fiction-archive/",
+ "ClientId": "ldi5IpEidq2WW0Ka1lehVskb2SOBjnYRaZCpEyBh",
+ "Audience": "ldi5IpEidq2WW0Ka1lehVskb2SOBjnYRaZCpEyBh",
+ "ValidIssuer": "https://auth.orfl.xyz/application/o/fiction-archive/",
+ "ValidateIssuer": true,
+ "ValidateAudience": true,
+ "ValidateLifetime": true,
+ "ValidateIssuerSigningKey": true
+ }
}
diff --git a/FictionArchive.Service.NovelService/subgraph-config.json b/FictionArchive.Service.NovelService/subgraph-config.json
index 0d9b586..e00edb6 100644
--- a/FictionArchive.Service.NovelService/subgraph-config.json
+++ b/FictionArchive.Service.NovelService/subgraph-config.json
@@ -1,6 +1,6 @@
{
"subgraph": "Novels",
"http": {
- "baseAddress": "http://localhost:5101/graphql"
+ "baseAddress": "https://localhost:7208/graphql"
}
}
\ No newline at end of file
diff --git a/FictionArchive.Service.SchedulerService/GraphQL/Mutation.cs b/FictionArchive.Service.SchedulerService/GraphQL/Mutation.cs
index f1a6b65..d79582c 100644
--- a/FictionArchive.Service.SchedulerService/GraphQL/Mutation.cs
+++ b/FictionArchive.Service.SchedulerService/GraphQL/Mutation.cs
@@ -1,6 +1,8 @@
using System.Data;
using FictionArchive.Service.SchedulerService.Models;
using FictionArchive.Service.SchedulerService.Services;
+using FictionArchive.Service.Shared.Constants;
+using HotChocolate.Authorization;
using HotChocolate.Types;
using Quartz;
@@ -10,18 +12,21 @@ public class Mutation
{
[Error]
[Error]
+ [Authorize(Roles = [AuthorizationConstants.Roles.Admin])]
public async Task ScheduleEventJob(string key, string description, string eventType, string eventData, string cronSchedule, JobManagerService jobManager)
{
return await jobManager.ScheduleEventJob(key, description, eventType, eventData, cronSchedule);
}
[Error]
+ [Authorize(Roles = [AuthorizationConstants.Roles.Admin])]
public async Task RunJob(string jobKey, JobManagerService jobManager)
{
return await jobManager.TriggerJob(jobKey);
}
[Error]
+ [Authorize(Roles = [AuthorizationConstants.Roles.Admin])]
public async Task DeleteJob(string jobKey, JobManagerService jobManager)
{
bool deleted = await jobManager.DeleteJob(jobKey);
diff --git a/FictionArchive.Service.SchedulerService/Program.cs b/FictionArchive.Service.SchedulerService/Program.cs
index 01e47d9..b1a9c92 100644
--- a/FictionArchive.Service.SchedulerService/Program.cs
+++ b/FictionArchive.Service.SchedulerService/Program.cs
@@ -17,10 +17,15 @@ public class Program
var builder = WebApplication.CreateBuilder(args);
// Services
- builder.Services.AddDefaultGraphQl();
+ builder.Services.AddDefaultGraphQl()
+ .AddAuthorization();
builder.Services.AddHealthChecks();
builder.Services.AddTransient();
-
+
+ // Authentication & Authorization
+ builder.Services.AddOidcAuthentication(builder.Configuration);
+ builder.Services.AddFictionArchiveAuthorization();
+
#region Database
builder.Services.RegisterDbContext(
@@ -87,7 +92,10 @@ public class Program
app.UseHttpsRedirection();
app.MapHealthChecks("/healthz");
-
+
+ app.UseAuthentication();
+ app.UseAuthorization();
+
app.MapGraphQL();
app.RunWithGraphQLCommands(args);
diff --git a/FictionArchive.Service.SchedulerService/appsettings.json b/FictionArchive.Service.SchedulerService/appsettings.json
index 6d027f1..043f775 100644
--- a/FictionArchive.Service.SchedulerService/appsettings.json
+++ b/FictionArchive.Service.SchedulerService/appsettings.json
@@ -12,5 +12,15 @@
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Database=FictionArchive_SchedulerService;Username=postgres;password=postgres"
},
- "AllowedHosts": "*"
+ "AllowedHosts": "*",
+ "OIDC": {
+ "Authority": "https://auth.orfl.xyz/application/o/fiction-archive/",
+ "ClientId": "fictionarchive-api",
+ "Audience": "fictionarchive-api",
+ "ValidIssuer": "https://auth.orfl.xyz/application/o/fiction-archive/",
+ "ValidateIssuer": true,
+ "ValidateAudience": true,
+ "ValidateLifetime": true,
+ "ValidateIssuerSigningKey": true
+ }
}
diff --git a/FictionArchive.Service.Shared/Constants/AuthorizationConstants.cs b/FictionArchive.Service.Shared/Constants/AuthorizationConstants.cs
new file mode 100644
index 0000000..79e7486
--- /dev/null
+++ b/FictionArchive.Service.Shared/Constants/AuthorizationConstants.cs
@@ -0,0 +1,15 @@
+namespace FictionArchive.Service.Shared.Constants;
+
+public static class AuthorizationConstants
+{
+ public static class Roles
+ {
+ public const string Admin = "admin";
+ }
+
+ public static class Policies
+ {
+ public const string Admin = "Admin";
+ public const string User = "User";
+ }
+}
diff --git a/FictionArchive.Service.Shared/Extensions/AuthenticationExtensions.cs b/FictionArchive.Service.Shared/Extensions/AuthenticationExtensions.cs
new file mode 100644
index 0000000..6f1c3e4
--- /dev/null
+++ b/FictionArchive.Service.Shared/Extensions/AuthenticationExtensions.cs
@@ -0,0 +1,168 @@
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging;
+using Microsoft.IdentityModel.Tokens;
+using FictionArchive.Service.Shared.Constants;
+using FictionArchive.Service.Shared.Models.Authentication;
+using System.Linq;
+
+namespace FictionArchive.Service.Shared.Extensions;
+
+public static class AuthenticationExtensions
+{
+ public static IServiceCollection AddOidcAuthentication(this IServiceCollection services, IConfiguration configuration)
+ {
+ var oidcConfig = configuration.GetSection("OIDC").Get();
+
+ if (oidcConfig == null)
+ {
+ throw new InvalidOperationException("OIDC configuration is required but not found in app settings");
+ }
+
+ ValidateOidcConfiguration(oidcConfig);
+
+ services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
+ .AddJwtBearer(options =>
+ {
+ options.Authority = oidcConfig.Authority;
+ options.Audience = oidcConfig.Audience;
+ options.RequireHttpsMetadata = !string.IsNullOrEmpty(oidcConfig.Authority) && oidcConfig.Authority.StartsWith("https://");
+
+ options.TokenValidationParameters = new TokenValidationParameters
+ {
+ ValidateIssuer = oidcConfig.ValidateIssuer,
+ ValidIssuer = oidcConfig.ValidIssuer,
+ ValidateAudience = oidcConfig.ValidateAudience,
+ ValidateLifetime = oidcConfig.ValidateLifetime,
+ ValidateIssuerSigningKey = oidcConfig.ValidateIssuerSigningKey,
+ ClockSkew = TimeSpan.FromMinutes(5)
+ };
+
+ options.Events = CreateLoggingJwtBearerEvents();
+ });
+
+ return services;
+ }
+
+ private static JwtBearerEvents CreateLoggingJwtBearerEvents(JwtBearerEvents? existingEvents = null)
+ {
+ return new JwtBearerEvents
+ {
+ OnMessageReceived = existingEvents?.OnMessageReceived ?? (_ => Task.CompletedTask),
+ OnAuthenticationFailed = context =>
+ {
+ var logger = context.HttpContext.RequestServices.GetRequiredService()
+ .CreateLogger("JwtBearerAuthentication");
+
+ logger.LogWarning(context.Exception, "JWT authentication failed: {Message}", context.Exception.Message);
+
+ return existingEvents?.OnAuthenticationFailed?.Invoke(context) ?? Task.CompletedTask;
+ },
+ OnChallenge = context =>
+ {
+ var logger = context.HttpContext.RequestServices.GetRequiredService()
+ .CreateLogger("JwtBearerAuthentication");
+
+ logger.LogDebug(
+ "JWT challenge issued. Error: {Error}, ErrorDescription: {ErrorDescription}",
+ context.Error,
+ context.ErrorDescription);
+
+ return existingEvents?.OnChallenge?.Invoke(context) ?? Task.CompletedTask;
+ },
+ OnTokenValidated = context =>
+ {
+ var logger = context.HttpContext.RequestServices.GetRequiredService()
+ .CreateLogger("JwtBearerAuthentication");
+
+ logger.LogDebug(
+ "JWT token validated for subject: {Subject}",
+ context.Principal?.FindFirst("sub")?.Value ?? "unknown");
+
+ return existingEvents?.OnTokenValidated?.Invoke(context) ?? Task.CompletedTask;
+ }
+ };
+ }
+
+ public static IServiceCollection AddOidcCookieAuthentication(this IServiceCollection services, IConfiguration configuration, string cookieName = "fa_session")
+ {
+ var oidcConfig = configuration.GetSection("OIDC").Get();
+
+ if (oidcConfig == null)
+ {
+ throw new InvalidOperationException("OIDC configuration is required but not found in app settings");
+ }
+
+ ValidateOidcConfiguration(oidcConfig);
+
+ services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
+ .AddJwtBearer(options =>
+ {
+ options.Authority = oidcConfig.Authority;
+ options.Audience = oidcConfig.Audience;
+ options.RequireHttpsMetadata = !string.IsNullOrEmpty(oidcConfig.Authority) && oidcConfig.Authority.StartsWith("https://");
+
+ var cookieEvents = new JwtBearerEvents
+ {
+ OnMessageReceived = context =>
+ {
+ // Try to get token from cookie first, then from Authorization header
+ if (context.Request.Cookies.ContainsKey(cookieName))
+ {
+ context.Token = context.Request.Cookies[cookieName];
+ }
+
+ return Task.CompletedTask;
+ }
+ };
+ options.Events = CreateLoggingJwtBearerEvents(cookieEvents);
+
+ options.TokenValidationParameters = new TokenValidationParameters
+ {
+ ValidateIssuer = oidcConfig.ValidateIssuer,
+ ValidIssuer = oidcConfig.ValidIssuer,
+ ValidateAudience = oidcConfig.ValidateAudience,
+ ValidateLifetime = oidcConfig.ValidateLifetime,
+ ValidateIssuerSigningKey = oidcConfig.ValidateIssuerSigningKey,
+ ClockSkew = TimeSpan.FromMinutes(5)
+ };
+ });
+
+ return services;
+ }
+
+ public static IServiceCollection AddFictionArchiveAuthorization(this IServiceCollection services)
+ {
+ services.AddAuthorizationBuilder()
+ .AddPolicy(AuthorizationConstants.Policies.Admin, policy => policy.RequireRole(AuthorizationConstants.Roles.Admin))
+ .AddPolicy(AuthorizationConstants.Policies.User, policy => policy.RequireAuthenticatedUser());
+
+ return services;
+ }
+
+ private static void ValidateOidcConfiguration(OidcConfiguration config)
+ {
+ var errors = new List();
+
+ if (string.IsNullOrWhiteSpace(config.Authority))
+ errors.Add("OIDC Authority is required");
+
+ if (string.IsNullOrWhiteSpace(config.ClientId))
+ errors.Add("OIDC ClientId is required");
+
+ if (string.IsNullOrWhiteSpace(config.Audience))
+ errors.Add("OIDC Audience is required");
+
+ if (!Uri.TryCreate(config.Authority, UriKind.Absolute, out var authorityUri))
+ errors.Add($"OIDC Authority '{config.Authority}' is not a valid URI");
+ else if (!authorityUri.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase) &&
+ !authorityUri.Host.Equals("localhost", StringComparison.OrdinalIgnoreCase))
+ errors.Add("OIDC Authority must use HTTPS unless running on localhost");
+
+ if (errors.Any())
+ {
+ throw new InvalidOperationException($"OIDC configuration validation failed:{Environment.NewLine}{string.Join(Environment.NewLine, errors)}");
+ }
+ }
+}
\ No newline at end of file
diff --git a/FictionArchive.Service.Shared/FictionArchive.Service.Shared.csproj b/FictionArchive.Service.Shared/FictionArchive.Service.Shared.csproj
index ee7c426..9e4fc17 100644
--- a/FictionArchive.Service.Shared/FictionArchive.Service.Shared.csproj
+++ b/FictionArchive.Service.Shared/FictionArchive.Service.Shared.csproj
@@ -9,6 +9,7 @@
+
@@ -29,6 +30,7 @@
+
diff --git a/FictionArchive.Service.Shared/Models/Authentication/OidcConfiguration.cs b/FictionArchive.Service.Shared/Models/Authentication/OidcConfiguration.cs
new file mode 100644
index 0000000..a1ee624
--- /dev/null
+++ b/FictionArchive.Service.Shared/Models/Authentication/OidcConfiguration.cs
@@ -0,0 +1,13 @@
+namespace FictionArchive.Service.Shared.Models.Authentication;
+
+public class OidcConfiguration
+{
+ public string Authority { get; set; } = string.Empty;
+ public string ClientId { get; set; } = string.Empty;
+ public string Audience { get; set; } = string.Empty;
+ public string? ValidIssuer { get; set; }
+ public bool ValidateIssuer { get; set; } = true;
+ public bool ValidateAudience { get; set; } = true;
+ public bool ValidateLifetime { get; set; } = true;
+ public bool ValidateIssuerSigningKey { get; set; } = true;
+}
\ No newline at end of file
diff --git a/FictionArchive.Service.TranslationService/GraphQL/Mutation.cs b/FictionArchive.Service.TranslationService/GraphQL/Mutation.cs
index d74a337..49873d9 100644
--- a/FictionArchive.Service.TranslationService/GraphQL/Mutation.cs
+++ b/FictionArchive.Service.TranslationService/GraphQL/Mutation.cs
@@ -5,15 +5,17 @@ using FictionArchive.Service.TranslationService.Models.Enums;
using FictionArchive.Service.TranslationService.Services;
using FictionArchive.Service.TranslationService.Services.Database;
using FictionArchive.Service.TranslationService.Services.TranslationEngines;
+using HotChocolate.Authorization;
namespace FictionArchive.Service.TranslationService.GraphQL;
public class Mutation
{
+ [Authorize]
public async Task TranslateText(string text, Language from, Language to, string translationEngineKey, TranslationEngineService translationEngineService)
{
var result = await translationEngineService.Translate(from, to, text, translationEngineKey);
-
+
return result;
}
}
\ No newline at end of file
diff --git a/FictionArchive.Service.TranslationService/GraphQL/Query.cs b/FictionArchive.Service.TranslationService/GraphQL/Query.cs
index 2b1c9f3..e753d54 100644
--- a/FictionArchive.Service.TranslationService/GraphQL/Query.cs
+++ b/FictionArchive.Service.TranslationService/GraphQL/Query.cs
@@ -2,19 +2,22 @@ using FictionArchive.Service.TranslationService.Models;
using FictionArchive.Service.TranslationService.Models.Database;
using FictionArchive.Service.TranslationService.Services.Database;
using FictionArchive.Service.TranslationService.Services.TranslationEngines;
+using HotChocolate.Authorization;
using Microsoft.EntityFrameworkCore;
namespace FictionArchive.Service.TranslationService.GraphQL;
public class Query
{
+ [Authorize]
[UseFiltering]
[UseSorting]
public IEnumerable GetTranslationEngines(IEnumerable engines)
{
return engines.Select(engine => engine.Descriptor);
}
-
+
+ [Authorize]
[UsePaging]
[UseProjection]
[UseFiltering]
diff --git a/FictionArchive.Service.TranslationService/Program.cs b/FictionArchive.Service.TranslationService/Program.cs
index c110f78..9f4972c 100644
--- a/FictionArchive.Service.TranslationService/Program.cs
+++ b/FictionArchive.Service.TranslationService/Program.cs
@@ -50,7 +50,8 @@ public class Program
#region GraphQL
- builder.Services.AddDefaultGraphQl();
+ builder.Services.AddDefaultGraphQl()
+ .AddAuthorization();
#endregion
@@ -63,9 +64,13 @@ public class Program
builder.Services.AddTransient();
builder.Services.AddTransient();
-
+
#endregion
+ // Authentication & Authorization
+ builder.Services.AddOidcAuthentication(builder.Configuration);
+ builder.Services.AddFictionArchiveAuthorization();
+
var app = builder.Build();
// Update database (skip in schema export mode)
@@ -79,7 +84,10 @@ public class Program
app.UseHttpsRedirection();
app.MapHealthChecks("/healthz");
-
+
+ app.UseAuthentication();
+ app.UseAuthorization();
+
app.MapGraphQL();
app.RunWithGraphQLCommands(args);
diff --git a/FictionArchive.Service.TranslationService/appsettings.json b/FictionArchive.Service.TranslationService/appsettings.json
index 4f0d4e1..c38b978 100644
--- a/FictionArchive.Service.TranslationService/appsettings.json
+++ b/FictionArchive.Service.TranslationService/appsettings.json
@@ -15,5 +15,15 @@
"ConnectionString": "amqp://localhost",
"ClientIdentifier": "TranslationService"
},
- "AllowedHosts": "*"
+ "AllowedHosts": "*",
+ "OIDC": {
+ "Authority": "https://auth.orfl.xyz/application/o/fiction-archive/",
+ "ClientId": "fictionarchive-api",
+ "Audience": "fictionarchive-api",
+ "ValidIssuer": "https://auth.orfl.xyz/application/o/fiction-archive/",
+ "ValidateIssuer": true,
+ "ValidateAudience": true,
+ "ValidateLifetime": true,
+ "ValidateIssuerSigningKey": true
+ }
}
diff --git a/FictionArchive.Service.UserService/GraphQL/Mutation.cs b/FictionArchive.Service.UserService/GraphQL/Mutation.cs
index dfd0409..4553b44 100644
--- a/FictionArchive.Service.UserService/GraphQL/Mutation.cs
+++ b/FictionArchive.Service.UserService/GraphQL/Mutation.cs
@@ -1,10 +1,13 @@
+using FictionArchive.Service.Shared.Constants;
using FictionArchive.Service.UserService.Models.Database;
using FictionArchive.Service.UserService.Services;
+using HotChocolate.Authorization;
namespace FictionArchive.Service.UserService.GraphQL;
public class Mutation
{
+ [Authorize(Roles = [AuthorizationConstants.Roles.Admin])]
public async Task RegisterUser(string username, string email, string oAuthProviderId,
string? inviterOAuthProviderId, UserManagementService userManagementService)
{
diff --git a/FictionArchive.Service.UserService/GraphQL/Query.cs b/FictionArchive.Service.UserService/GraphQL/Query.cs
index c6ceb62..9049fa8 100644
--- a/FictionArchive.Service.UserService/GraphQL/Query.cs
+++ b/FictionArchive.Service.UserService/GraphQL/Query.cs
@@ -1,10 +1,12 @@
using FictionArchive.Service.UserService.Models.Database;
using FictionArchive.Service.UserService.Services;
+using HotChocolate.Authorization;
namespace FictionArchive.Service.UserService.GraphQL;
public class Query
{
+ [Authorize]
public async Task> GetUsers(UserManagementService userManagementService)
{
return userManagementService.GetUsers();
diff --git a/FictionArchive.Service.UserService/Program.cs b/FictionArchive.Service.UserService/Program.cs
index efc54e9..1398ce9 100644
--- a/FictionArchive.Service.UserService/Program.cs
+++ b/FictionArchive.Service.UserService/Program.cs
@@ -1,3 +1,4 @@
+using FictionArchive.Common.Extensions;
using FictionArchive.Service.Shared;
using FictionArchive.Service.Shared.Extensions;
using FictionArchive.Service.Shared.Services.EventBus.Implementations;
@@ -15,7 +16,8 @@ public class Program
var isSchemaExport = SchemaExportDetector.IsSchemaExportMode(args);
var builder = WebApplication.CreateBuilder(args);
-
+ builder.AddLocalAppsettings();
+
#region Event Bus
if (!isSchemaExport)
@@ -31,7 +33,8 @@ public class Program
#region GraphQL
- builder.Services.AddDefaultGraphQl();
+ builder.Services.AddDefaultGraphQl()
+ .AddAuthorization();
#endregion
@@ -41,7 +44,11 @@ public class Program
builder.Services.AddTransient();
builder.Services.AddHealthChecks();
-
+
+ // Authentication & Authorization
+ builder.Services.AddOidcAuthentication(builder.Configuration);
+ builder.Services.AddFictionArchiveAuthorization();
+
var app = builder.Build();
// Update database (skip in schema export mode)
@@ -52,8 +59,11 @@ public class Program
dbContext.UpdateDatabase();
}
+ app.UseAuthentication();
+ app.UseAuthorization();
+
app.MapGraphQL();
-
+
app.MapHealthChecks("/healthz");
app.RunWithGraphQLCommands(args);
diff --git a/FictionArchive.Service.UserService/appsettings.json b/FictionArchive.Service.UserService/appsettings.json
index ac07d77..f6ac2c3 100644
--- a/FictionArchive.Service.UserService/appsettings.json
+++ b/FictionArchive.Service.UserService/appsettings.json
@@ -12,5 +12,15 @@
"ConnectionString": "amqp://localhost",
"ClientIdentifier": "UserService"
},
- "AllowedHosts": "*"
+ "AllowedHosts": "*",
+ "OIDC": {
+ "Authority": "https://auth.orfl.xyz/application/o/fiction-archive/",
+ "ClientId": "fictionarchive-api",
+ "Audience": "fictionarchive-api",
+ "ValidIssuer": "https://auth.orfl.xyz/application/o/fiction-archive/",
+ "ValidateIssuer": true,
+ "ValidateAudience": true,
+ "ValidateLifetime": true,
+ "ValidateIssuerSigningKey": true
+ }
}
diff --git a/docker-compose.yml b/docker-compose.yml
index 2978d78..1590f28 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -128,6 +128,9 @@ services:
S3__AccessKey: ${S3_ACCESS_KEY}
S3__SecretKey: ${S3_SECRET_KEY}
Proxy__BaseUrl: https://files.orfl.xyz/api
+ OIDC__Authority: https://auth.orfl.xyz/application/o/fictionarchive/
+ OIDC__ClientId: fictionarchive-files
+ OIDC__Audience: fictionarchive-api
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/healthz"]
interval: 30s
@@ -151,6 +154,9 @@ services:
image: git.orfl.xyz/conco/fictionarchive-api:latest
environment:
ConnectionStrings__RabbitMQ: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq
+ OIDC__Authority: https://auth.orfl.xyz/application/o/fictionarchive/
+ OIDC__ClientId: fictionarchive-api
+ OIDC__Audience: fictionarchive-api
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/healthz"]
interval: 30s
diff --git a/fictionarchive-web/src/auth/AuthContext.tsx b/fictionarchive-web/src/auth/AuthContext.tsx
index 6ccb637..e46345e 100644
--- a/fictionarchive-web/src/auth/AuthContext.tsx
+++ b/fictionarchive-web/src/auth/AuthContext.tsx
@@ -2,6 +2,29 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use
import type { User } from 'oidc-client-ts'
import { isOidcConfigured, userManager } from './oidcClient'
+// Cookie management helper functions
+function setCookieFromUser(user: User) {
+ if (!user?.access_token) return
+
+ const isProduction = window.location.hostname !== 'localhost'
+ const domain = isProduction ? '.orfl.xyz' : undefined
+ const secure = isProduction
+ const sameSite = isProduction ? 'None' : 'Lax'
+
+ // Set cookie with JWT token from user
+ const cookieValue = `fa_session=${user.access_token}; path=/; ${secure ? 'secure; ' : ''}samesite=${sameSite}${domain ? `; domain=${domain}` : ''}`
+ document.cookie = cookieValue
+}
+
+function clearFaSessionCookie() {
+ const isProduction = window.location.hostname !== 'localhost'
+ const domain = isProduction ? '.orfl.xyz' : undefined
+
+ // Clear cookie by setting expiration date in the past
+ const cookieValue = `fa_session=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT${domain ? `; domain=${domain}` : ''}`
+ document.cookie = cookieValue
+}
+
type AuthContextValue = {
user: User | null
isLoading: boolean
@@ -26,7 +49,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
userManager
.getUser()
.then((loadedUser) => {
- if (!cancelled) setUser(loadedUser ?? null)
+ if (!cancelled) {
+ setUser(loadedUser ?? null)
+ if (loadedUser) {
+ setCookieFromUser(loadedUser)
+ }
+ }
})
.finally(() => {
if (!cancelled) setIsLoading(false)
@@ -41,8 +69,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const manager = userManager
if (!manager) return
- const handleLoaded = (nextUser: User) => setUser(nextUser)
- const handleUnloaded = () => setUser(null)
+ const handleLoaded = (nextUser: User) => {
+ setUser(nextUser)
+ setCookieFromUser(nextUser)
+ }
+ const handleUnloaded = () => {
+ setUser(null)
+ clearFaSessionCookie()
+ }
manager.events.addUserLoaded(handleLoaded)
manager.events.addUserUnloaded(handleUnloaded)
@@ -72,6 +106,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
.signinRedirectCallback()
.then((nextUser) => {
setUser(nextUser ?? null)
+ if (nextUser) {
+ setCookieFromUser(nextUser)
+ }
})
.catch((error) => {
console.error('Failed to complete sign-in redirect', error)
@@ -103,6 +140,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
console.error('Failed to sign out via redirect, clearing local session instead.', error)
await manager.removeUser()
setUser(null)
+ clearFaSessionCookie()
}
}, [])