4 Commits

Author SHA1 Message Date
gamer147
75e96cbee5 [FA-17] Update auth
All checks were successful
CI / build-backend (pull_request) Successful in 1m13s
CI / build-frontend (pull_request) Successful in 34s
2025-11-27 23:23:03 -05:00
Claude
9c82d648cd fix: address authentication system issues
- Fix GraphQL authorization attributes to use string[] instead of string for roles
- Remove admin role requirement from ImportNovel endpoint
- Add comprehensive OIDC configuration validation with specific error messages
- Validate Authority, ClientId, and Audience are properly configured
- Ensure HTTPS requirement except for localhost development

Co-authored-by: conco <conco@users.noreply.local>
2025-11-27 16:20:09 +00:00
Claude
78612ea29d feat: implement authentication system for API Gateway and FileService
Some checks failed
CI / build-backend (pull_request) Failing after 1m12s
CI / build-frontend (pull_request) Successful in 28s
- Add JWT Bearer token validation to API Gateway with restricted CORS
- Add cookie-based JWT validation to FileService for browser image requests
- Create shared authentication infrastructure in FictionArchive.Service.Shared
- Update frontend to set fa_session cookie after OIDC login
- Add [Authorize] attributes to GraphQL mutations with role-based restrictions
- Configure OIDC settings for both services in docker-compose

Implements FA-17: Authentication for microservices architecture
2025-11-27 14:05:54 +00:00
4412a1f658 Merge pull request 'feature/FA-11_CICD' (#33) from feature/FA-11_CICD into master
All checks were successful
CI / build-backend (push) Successful in 54s
CI / build-frontend (push) Successful in 26s
Reviewed-on: #33
2025-11-26 23:39:45 +00:00
29 changed files with 408 additions and 30 deletions

View File

@@ -13,6 +13,7 @@
<PackageReference Include="HotChocolate.Data.EntityFramework" Version="15.1.11" />
<PackageReference Include="HotChocolate.Fusion" Version="15.1.11" />
<PackageReference Include="HotChocolate.Types.Scalars" Version="15.1.11" />
<PackageReference Include="Microsoft.AspNetCore.HeaderPropagation" Version="8.0.22" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.11">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@@ -20,6 +21,7 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.11" />
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="8.0.7" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2"/>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11" />
</ItemGroup>
<!-- Builds the Fusion graph file before building the application itself (skipped in CI) -->

View File

@@ -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);

View File

@@ -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
}
}

View File

@@ -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;

View File

@@ -21,6 +21,7 @@
<PackageReference Include="AWSSDK.S3" Version="4.0.13.1" />
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="9.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11" />
</ItemGroup>
<ItemGroup>

View File

@@ -34,6 +34,10 @@ public class Program
#endregion
// Add authentication with cookie support
builder.Services.AddOidcCookieAuthentication(builder.Configuration);
builder.Services.AddFictionArchiveAuthorization();
builder.Services.Configure<ProxyConfiguration>(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();

View File

@@ -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": "*"
}

View File

@@ -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<NovelUpdateRequestedEvent> ImportNovel(string novelUrl, NovelUpdateService service)
{
return await service.QueueNovelImport(novelUrl);
}
[Authorize]
public async Task<ChapterPullRequestedEvent> FetchChapterContents(uint novelId,
uint chapterNumber,
NovelUpdateService service)

View File

@@ -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]

View File

@@ -43,7 +43,8 @@ public class Program
#region GraphQL
builder.Services.AddDefaultGraphQl<Query, Mutation>();
builder.Services.AddDefaultGraphQl<Query, Mutation>()
.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)
@@ -89,6 +94,9 @@ public class Program
app.MapHealthChecks("/healthz");
app.UseAuthentication();
app.UseAuthorization();
app.MapGraphQL();
app.RunWithGraphQLCommands(args);

View File

@@ -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
}
}

View File

@@ -1,6 +1,6 @@
{
"subgraph": "Novels",
"http": {
"baseAddress": "http://localhost:5101/graphql"
"baseAddress": "https://localhost:7208/graphql"
}
}

View File

@@ -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<DuplicateNameException>]
[Error<FormatException>]
[Authorize(Roles = [AuthorizationConstants.Roles.Admin])]
public async Task<SchedulerJob> ScheduleEventJob(string key, string description, string eventType, string eventData, string cronSchedule, JobManagerService jobManager)
{
return await jobManager.ScheduleEventJob(key, description, eventType, eventData, cronSchedule);
}
[Error<JobPersistenceException>]
[Authorize(Roles = [AuthorizationConstants.Roles.Admin])]
public async Task<bool> RunJob(string jobKey, JobManagerService jobManager)
{
return await jobManager.TriggerJob(jobKey);
}
[Error<KeyNotFoundException>]
[Authorize(Roles = [AuthorizationConstants.Roles.Admin])]
public async Task<bool> DeleteJob(string jobKey, JobManagerService jobManager)
{
bool deleted = await jobManager.DeleteJob(jobKey);

View File

@@ -17,10 +17,15 @@ public class Program
var builder = WebApplication.CreateBuilder(args);
// Services
builder.Services.AddDefaultGraphQl<Query, Mutation>();
builder.Services.AddDefaultGraphQl<Query, Mutation>()
.AddAuthorization();
builder.Services.AddHealthChecks();
builder.Services.AddTransient<JobManagerService>();
// Authentication & Authorization
builder.Services.AddOidcAuthentication(builder.Configuration);
builder.Services.AddFictionArchiveAuthorization();
#region Database
builder.Services.RegisterDbContext<SchedulerServiceDbContext>(
@@ -88,6 +93,9 @@ public class Program
app.MapHealthChecks("/healthz");
app.UseAuthentication();
app.UseAuthorization();
app.MapGraphQL();
app.RunWithGraphQLCommands(args);

View File

@@ -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
}
}

View File

@@ -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";
}
}

View File

@@ -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<OidcConfiguration>();
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<ILoggerFactory>()
.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<ILoggerFactory>()
.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<ILoggerFactory>()
.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<OidcConfiguration>();
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<string>();
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)}");
}
}
}

View File

@@ -9,6 +9,7 @@
<ItemGroup>
<PackageReference Include="GraphQL.Server.Ui.GraphiQL" Version="8.3.3" />
<PackageReference Include="HotChocolate.AspNetCore" Version="15.1.11" />
<PackageReference Include="HotChocolate.AspNetCore.Authorization" Version="15.1.11" />
<PackageReference Include="HotChocolate.AspNetCore.CommandLine" Version="15.1.11" />
<PackageReference Include="HotChocolate.Data.EntityFramework" Version="15.1.11" />
<PackageReference Include="HotChocolate.Types.Scalars" Version="15.1.11" />
@@ -29,6 +30,7 @@
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" />
<PackageReference Include="RabbitMQ.Client" Version="7.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11" />
</ItemGroup>
<ItemGroup>

View File

@@ -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;
}

View File

@@ -5,11 +5,13 @@ 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<TranslationResult> TranslateText(string text, Language from, Language to, string translationEngineKey, TranslationEngineService translationEngineService)
{
var result = await translationEngineService.Translate(from, to, text, translationEngineKey);

View File

@@ -2,12 +2,14 @@ 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<TranslationEngineDescriptor> GetTranslationEngines(IEnumerable<ITranslationEngine> engines)
@@ -15,6 +17,7 @@ public class Query
return engines.Select(engine => engine.Descriptor);
}
[Authorize]
[UsePaging]
[UseProjection]
[UseFiltering]

View File

@@ -50,7 +50,8 @@ public class Program
#region GraphQL
builder.Services.AddDefaultGraphQl<Query, Mutation>();
builder.Services.AddDefaultGraphQl<Query, Mutation>()
.AddAuthorization();
#endregion
@@ -66,6 +67,10 @@ public class Program
#endregion
// Authentication & Authorization
builder.Services.AddOidcAuthentication(builder.Configuration);
builder.Services.AddFictionArchiveAuthorization();
var app = builder.Build();
// Update database (skip in schema export mode)
@@ -80,6 +85,9 @@ public class Program
app.MapHealthChecks("/healthz");
app.UseAuthentication();
app.UseAuthorization();
app.MapGraphQL();
app.RunWithGraphQLCommands(args);

View File

@@ -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
}
}

View File

@@ -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<User> RegisterUser(string username, string email, string oAuthProviderId,
string? inviterOAuthProviderId, UserManagementService userManagementService)
{

View File

@@ -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<IQueryable<User>> GetUsers(UserManagementService userManagementService)
{
return userManagementService.GetUsers();

View File

@@ -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,6 +16,7 @@ public class Program
var isSchemaExport = SchemaExportDetector.IsSchemaExportMode(args);
var builder = WebApplication.CreateBuilder(args);
builder.AddLocalAppsettings();
#region Event Bus
@@ -31,7 +33,8 @@ public class Program
#region GraphQL
builder.Services.AddDefaultGraphQl<Query, Mutation>();
builder.Services.AddDefaultGraphQl<Query, Mutation>()
.AddAuthorization();
#endregion
@@ -42,6 +45,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)
@@ -52,6 +59,9 @@ public class Program
dbContext.UpdateDatabase();
}
app.UseAuthentication();
app.UseAuthorization();
app.MapGraphQL();
app.MapHealthChecks("/healthz");

View File

@@ -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
}
}

View File

@@ -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

View File

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