diff --git a/FictionArchive.API/FictionArchive.API.csproj b/FictionArchive.API/FictionArchive.API.csproj
index 73638d7..610d285 100644
--- a/FictionArchive.API/FictionArchive.API.csproj
+++ b/FictionArchive.API/FictionArchive.API.csproj
@@ -20,6 +20,7 @@
+
diff --git a/FictionArchive.API/Program.cs b/FictionArchive.API/Program.cs
index 8ee5e37..5dc50b0 100644
--- a/FictionArchive.API/Program.cs
+++ b/FictionArchive.API/Program.cs
@@ -21,20 +21,28 @@ public class Program
#endregion
+ // Add authentication
+ builder.Services.AddOidcAuthentication(builder.Configuration);
+ builder.Services.AddFictionArchiveAuthorization();
+
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.UseAuthentication();
+ app.UseAuthorization();
app.MapHealthChecks("/healthz");
diff --git a/FictionArchive.API/appsettings.json b/FictionArchive.API/appsettings.json
index 10f68b8..c0cd8d8 100644
--- a/FictionArchive.API/appsettings.json
+++ b/FictionArchive.API/appsettings.json
@@ -5,5 +5,14 @@
"Microsoft.AspNetCore": "Warning"
}
},
- "AllowedHosts": "*"
+ "AllowedHosts": "*",
+ "OIDC": {
+ "Authority": "https://auth.orfl.xyz/application/o/fictionarchive/",
+ "ClientId": "fictionarchive-api",
+ "Audience": "fictionarchive-api",
+ "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..b893f23 100644
--- a/FictionArchive.Service.FileService/appsettings.json
+++ b/FictionArchive.Service.FileService/appsettings.json
@@ -18,5 +18,14 @@
"AccessKey": "REPLACE_ME",
"SecretKey": "REPLACE_ME"
},
+ "OIDC": {
+ "Authority": "https://auth.orfl.xyz/application/o/fictionarchive/",
+ "ClientId": "fictionarchive-files",
+ "Audience": "fictionarchive-api",
+ "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..b870439 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(Roles = "admin")]
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.SchedulerService/GraphQL/Mutation.cs b/FictionArchive.Service.SchedulerService/GraphQL/Mutation.cs
index f1a6b65..3e0ff6f 100644
--- a/FictionArchive.Service.SchedulerService/GraphQL/Mutation.cs
+++ b/FictionArchive.Service.SchedulerService/GraphQL/Mutation.cs
@@ -1,6 +1,7 @@
using System.Data;
using FictionArchive.Service.SchedulerService.Models;
using FictionArchive.Service.SchedulerService.Services;
+using HotChocolate.Authorization;
using HotChocolate.Types;
using Quartz;
@@ -10,18 +11,21 @@ public class Mutation
{
[Error]
[Error]
+ [Authorize(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 = "admin")]
public async Task RunJob(string jobKey, JobManagerService jobManager)
{
return await jobManager.TriggerJob(jobKey);
}
[Error]
+ [Authorize(Roles = "admin")]
public async Task DeleteJob(string jobKey, JobManagerService jobManager)
{
bool deleted = await jobManager.DeleteJob(jobKey);
diff --git a/FictionArchive.Service.Shared/Extensions/AuthenticationExtensions.cs b/FictionArchive.Service.Shared/Extensions/AuthenticationExtensions.cs
new file mode 100644
index 0000000..331f9f6
--- /dev/null
+++ b/FictionArchive.Service.Shared/Extensions/AuthenticationExtensions.cs
@@ -0,0 +1,91 @@
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Configuration;
+using Microsoft.IdentityModel.Tokens;
+using FictionArchive.Service.Shared.Models.Authentication;
+
+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");
+ }
+
+ 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,
+ ValidateAudience = oidcConfig.ValidateAudience,
+ ValidateLifetime = oidcConfig.ValidateLifetime,
+ ValidateIssuerSigningKey = oidcConfig.ValidateIssuerSigningKey,
+ ClockSkew = TimeSpan.FromMinutes(5)
+ };
+ });
+
+ return services;
+ }
+
+ 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");
+ }
+
+ 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.Events = 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.TokenValidationParameters = new TokenValidationParameters
+ {
+ ValidateIssuer = oidcConfig.ValidateIssuer,
+ 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("Admin", policy => policy.RequireRole("admin"))
+ .AddPolicy("User", policy => policy.RequireAuthenticatedUser());
+
+ return services;
+ }
+}
\ 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..4a79b26 100644
--- a/FictionArchive.Service.Shared/FictionArchive.Service.Shared.csproj
+++ b/FictionArchive.Service.Shared/FictionArchive.Service.Shared.csproj
@@ -29,6 +29,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..13fe4c0
--- /dev/null
+++ b/FictionArchive.Service.Shared/Models/Authentication/OidcConfiguration.cs
@@ -0,0 +1,12 @@
+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 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/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()
}
}, [])