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() } }, [])