diff --git a/backend/Books.Api/Authentication/ApiKeyAuthenticationHandler.cs b/backend/Books.Api/Authentication/ApiKeyAuthenticationHandler.cs new file mode 100644 index 0000000..648edb6 --- /dev/null +++ b/backend/Books.Api/Authentication/ApiKeyAuthenticationHandler.cs @@ -0,0 +1,114 @@ +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using System.Text.Encodings.Web; +using Books.Api.EventFlow.Repositories; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; + +namespace Books.Api.Authentication; + +public class ApiKeyAuthenticationOptions : AuthenticationSchemeOptions +{ + public string HeaderName { get; set; } = ApiKeyDefaults.HeaderName; + public TimeSpan CacheDuration { get; set; } = TimeSpan.FromHours(24); +} + +public class ApiKeyAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + IMemoryCache cache, + IServiceProvider serviceProvider) + : AuthenticationHandler(options, logger, encoder) +{ + protected override async Task HandleAuthenticateAsync() + { + if (!Request.Headers.TryGetValue(Options.HeaderName, out var apiKeyHeader)) + { + return AuthenticateResult.NoResult(); + } + + var apiKeyValue = apiKeyHeader.ToString(); + if (string.IsNullOrWhiteSpace(apiKeyValue)) + { + return AuthenticateResult.NoResult(); + } + + // Expected format: {keyId}.{secret} + var parts = apiKeyValue.Split('.', 2); + if (parts.Length != 2) + { + return AuthenticateResult.Fail("Invalid API key format. Expected: {keyId}.{secret}"); + } + + var keyId = parts[0]; + var secret = parts[1]; + + // Check cache first + var cacheKey = $"apikey:{keyId}"; + if (cache.TryGetValue(cacheKey, out CachedApiKeyInfo? cachedInfo) && cachedInfo != null) + { + var providedHash = HashSecret(secret); + if (cachedInfo.KeyHash == providedHash) + { + return AuthenticateResult.Success( + new AuthenticationTicket(cachedInfo.Principal, Scheme.Name)); + } + + return AuthenticateResult.Fail("Invalid API key"); + } + + // Cache miss - lookup in database + using var scope = serviceProvider.CreateScope(); + var repository = scope.ServiceProvider.GetService(); + + if (repository == null) + { + Logger.LogWarning("IApiKeyRepository not registered - API key authentication unavailable"); + return AuthenticateResult.NoResult(); + } + + var apiKey = await repository.GetByIdForValidationAsync(keyId); + if (apiKey == null) + { + return AuthenticateResult.Fail("Invalid API key"); + } + + // Verify hash + var secretHash = HashSecret(secret); + if (apiKey.KeyHash != secretHash) + { + return AuthenticateResult.Fail("Invalid API key"); + } + + // Build claims + var claims = new List + { + new(ClaimTypes.NameIdentifier, apiKey.ApiKeyId), + new(ClaimTypes.Name, apiKey.Name), + new("company_id", apiKey.CompanyId), + new("preferred_username", $"apikey:{apiKey.Name}"), + new(ClaimTypes.Role, "api_client"), + new(ClaimTypes.AuthenticationMethod, "api_key") + }; + + var identity = new ClaimsIdentity(claims, Scheme.Name); + var principal = new ClaimsPrincipal(identity); + + // Cache the result + var cacheInfo = new CachedApiKeyInfo(secretHash, principal); + cache.Set(cacheKey, cacheInfo, Options.CacheDuration); + + return AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name)); + } + + private static string HashSecret(string secret) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(secret)); + return Convert.ToHexString(bytes).ToLowerInvariant(); + } + + private sealed record CachedApiKeyInfo(string KeyHash, ClaimsPrincipal Principal); +} diff --git a/backend/Books.Api/Authentication/ApiKeyDefaults.cs b/backend/Books.Api/Authentication/ApiKeyDefaults.cs new file mode 100644 index 0000000..e339d85 --- /dev/null +++ b/backend/Books.Api/Authentication/ApiKeyDefaults.cs @@ -0,0 +1,7 @@ +namespace Books.Api.Authentication; + +public static class ApiKeyDefaults +{ + public const string AuthenticationScheme = "ApiKey"; + public const string HeaderName = "x-api-key"; +} diff --git a/backend/Books.Api/Authentication/UserExtensions.cs b/backend/Books.Api/Authentication/UserExtensions.cs new file mode 100644 index 0000000..bec16b6 --- /dev/null +++ b/backend/Books.Api/Authentication/UserExtensions.cs @@ -0,0 +1,34 @@ +using System.Security.Claims; + +namespace Books.Api.Authentication; + +public static class UserExtensions +{ + public static UserContext? GetUserContext(this ClaimsPrincipal? principal) + { + if (principal?.Identity?.IsAuthenticated != true) + return null; + + return new UserContext( + Id: principal.FindFirst(ClaimTypes.NameIdentifier)?.Value, + Email: principal.FindFirst(ClaimTypes.Email)?.Value + ?? principal.FindFirst("preferred_username")?.Value, + Name: principal.FindFirst(ClaimTypes.GivenName)?.Value + ?? principal.FindFirst(ClaimTypes.Name)?.Value, + CompanyId: principal.FindFirst("company_id")?.Value, + IsApiKey: principal.FindFirst(ClaimTypes.AuthenticationMethod)?.Value == "api_key" + ); + } + + public static string? GetClaimValue(this ClaimsPrincipal principal, string claimType) + { + return principal.FindFirst(claimType)?.Value; + } +} + +public record UserContext( + string? Id, + string? Email, + string? Name, + string? CompanyId, + bool IsApiKey); diff --git a/backend/Books.Api/Books.Api.csproj b/backend/Books.Api/Books.Api.csproj index 7115076..cef3f4f 100644 --- a/backend/Books.Api/Books.Api.csproj +++ b/backend/Books.Api/Books.Api.csproj @@ -37,6 +37,9 @@ + + + diff --git a/backend/Books.Api/Commands/ApiKeys/ApiKeyCommandHandlers.cs b/backend/Books.Api/Commands/ApiKeys/ApiKeyCommandHandlers.cs new file mode 100644 index 0000000..1629e53 --- /dev/null +++ b/backend/Books.Api/Commands/ApiKeys/ApiKeyCommandHandlers.cs @@ -0,0 +1,30 @@ +using Books.Api.Domain.ApiKeys; +using EventFlow.Commands; + +namespace Books.Api.Commands.ApiKeys; + +public class CreateApiKeyCommandHandler + : CommandHandler +{ + public override Task ExecuteAsync( + ApiKeyAggregate aggregate, + CreateApiKeyCommand command, + CancellationToken cancellationToken) + { + aggregate.Create(command.Name, command.KeyHash, command.CompanyId, command.CreatedBy); + return Task.CompletedTask; + } +} + +public class RevokeApiKeyCommandHandler + : CommandHandler +{ + public override Task ExecuteAsync( + ApiKeyAggregate aggregate, + RevokeApiKeyCommand command, + CancellationToken cancellationToken) + { + aggregate.Revoke(command.RevokedBy); + return Task.CompletedTask; + } +} diff --git a/backend/Books.Api/Commands/ApiKeys/ApiKeyCommands.cs b/backend/Books.Api/Commands/ApiKeys/ApiKeyCommands.cs new file mode 100644 index 0000000..3574d59 --- /dev/null +++ b/backend/Books.Api/Commands/ApiKeys/ApiKeyCommands.cs @@ -0,0 +1,24 @@ +using Books.Api.Domain.ApiKeys; +using EventFlow.Commands; + +namespace Books.Api.Commands.ApiKeys; + +public class CreateApiKeyCommand( + ApiKeyId aggregateId, + string name, + string keyHash, + string companyId, + string createdBy) : Command(aggregateId) +{ + public string Name { get; } = name; + public string KeyHash { get; } = keyHash; + public string CompanyId { get; } = companyId; + public string CreatedBy { get; } = createdBy; +} + +public class RevokeApiKeyCommand( + ApiKeyId aggregateId, + string revokedBy) : Command(aggregateId) +{ + public string RevokedBy { get; } = revokedBy; +} diff --git a/backend/Books.Api/Controllers/AuthController.cs b/backend/Books.Api/Controllers/AuthController.cs new file mode 100644 index 0000000..998973f --- /dev/null +++ b/backend/Books.Api/Controllers/AuthController.cs @@ -0,0 +1,41 @@ +using Books.Api.Authentication; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Books.Api.Controllers; + +[Route("api")] +[ApiController] +public class AuthController : ControllerBase +{ + [HttpGet("login")] + [Authorize] + public IActionResult Login([FromQuery] string? returnUrl) + { + // The [Authorize] attribute triggers the OIDC challenge if not authenticated. + // If we reach here, the user is authenticated - redirect back to the app. + return Redirect(returnUrl ?? "/"); + } + + [HttpGet("logout")] + public async Task Logout() + { + await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + return Ok(new { message = "Logged out successfully" }); + } + + [HttpGet("profile")] + [Authorize] + public IActionResult Profile() + { + var userContext = User.GetUserContext(); + if (userContext == null) + { + return Unauthorized(); + } + + return Ok(userContext); + } +} diff --git a/backend/Books.Api/Database/Migrations/002_ApiKeys.sql b/backend/Books.Api/Database/Migrations/002_ApiKeys.sql new file mode 100644 index 0000000..35aefb1 --- /dev/null +++ b/backend/Books.Api/Database/Migrations/002_ApiKeys.sql @@ -0,0 +1,21 @@ +-- API Keys table for programmatic access authentication +CREATE TABLE IF NOT EXISTS apikey_read_models ( + aggregate_id VARCHAR(255) PRIMARY KEY, + create_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_aggregate_sequence_number INT NOT NULL DEFAULT 1, + + name VARCHAR(255) NOT NULL, + key_hash VARCHAR(64) NOT NULL, + company_id VARCHAR(255) NOT NULL, + created_by VARCHAR(255) NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + revoked_time TIMESTAMPTZ, + revoked_by VARCHAR(255) +); + +-- Index for looking up API keys by company +CREATE INDEX IF NOT EXISTS idx_apikey_company ON apikey_read_models(company_id); + +-- Partial index for efficient lookups of active API keys +CREATE INDEX IF NOT EXISTS idx_apikey_active ON apikey_read_models(aggregate_id) WHERE is_active = true; diff --git a/backend/Books.Api/Domain/ApiKeys/ApiKeyAggregate.cs b/backend/Books.Api/Domain/ApiKeys/ApiKeyAggregate.cs new file mode 100644 index 0000000..36bfcc2 --- /dev/null +++ b/backend/Books.Api/Domain/ApiKeys/ApiKeyAggregate.cs @@ -0,0 +1,49 @@ +using Books.Api.Domain.ApiKeys.Events; +using EventFlow.Aggregates; + +namespace Books.Api.Domain.ApiKeys; + +public class ApiKeyAggregate : AggregateRoot, + IEmit, + IEmit +{ + public new string Name { get; private set; } = string.Empty; + public string KeyHash { get; private set; } = string.Empty; + public string CompanyId { get; private set; } = string.Empty; + public string CreatedBy { get; private set; } = string.Empty; + public bool IsActive { get; private set; } = true; + public string? RevokedBy { get; private set; } + + public ApiKeyAggregate(ApiKeyId id) : base(id) { } + + public void Create(string name, string keyHash, string companyId, string createdBy) + { + if (!IsNew) + throw new DomainException("APIKEY_EXISTS", "API key already exists", "API nøgle eksisterer allerede"); + + Emit(new ApiKeyCreatedEvent(name, keyHash, companyId, createdBy)); + } + + public void Revoke(string revokedBy) + { + if (!IsActive) + throw new DomainException("APIKEY_REVOKED", "API key is already revoked", "API nøgle er allerede tilbagekaldt"); + + Emit(new ApiKeyRevokedEvent(revokedBy)); + } + + public void Apply(ApiKeyCreatedEvent e) + { + Name = e.Name; + KeyHash = e.KeyHash; + CompanyId = e.CompanyId; + CreatedBy = e.CreatedBy; + IsActive = true; + } + + public void Apply(ApiKeyRevokedEvent e) + { + IsActive = false; + RevokedBy = e.RevokedBy; + } +} diff --git a/backend/Books.Api/Domain/ApiKeys/ApiKeyId.cs b/backend/Books.Api/Domain/ApiKeys/ApiKeyId.cs new file mode 100644 index 0000000..0454e9f --- /dev/null +++ b/backend/Books.Api/Domain/ApiKeys/ApiKeyId.cs @@ -0,0 +1,8 @@ +using EventFlow.Core; + +namespace Books.Api.Domain.ApiKeys; + +public class ApiKeyId : Identity +{ + public ApiKeyId(string value) : base(value) { } +} diff --git a/backend/Books.Api/Domain/ApiKeys/Events/ApiKeyCreatedEvent.cs b/backend/Books.Api/Domain/ApiKeys/Events/ApiKeyCreatedEvent.cs new file mode 100644 index 0000000..0361e13 --- /dev/null +++ b/backend/Books.Api/Domain/ApiKeys/Events/ApiKeyCreatedEvent.cs @@ -0,0 +1,15 @@ +using EventFlow.Aggregates; + +namespace Books.Api.Domain.ApiKeys.Events; + +public class ApiKeyCreatedEvent( + string name, + string keyHash, + string companyId, + string createdBy) : AggregateEvent +{ + public string Name { get; } = name; + public string KeyHash { get; } = keyHash; + public string CompanyId { get; } = companyId; + public string CreatedBy { get; } = createdBy; +} diff --git a/backend/Books.Api/Domain/ApiKeys/Events/ApiKeyRevokedEvent.cs b/backend/Books.Api/Domain/ApiKeys/Events/ApiKeyRevokedEvent.cs new file mode 100644 index 0000000..d497bdd --- /dev/null +++ b/backend/Books.Api/Domain/ApiKeys/Events/ApiKeyRevokedEvent.cs @@ -0,0 +1,8 @@ +using EventFlow.Aggregates; + +namespace Books.Api.Domain.ApiKeys.Events; + +public class ApiKeyRevokedEvent(string revokedBy) : AggregateEvent +{ + public string RevokedBy { get; } = revokedBy; +} diff --git a/backend/Books.Api/EventFlow/Extensions/ReadModelRegistrationExtensions.cs b/backend/Books.Api/EventFlow/Extensions/ReadModelRegistrationExtensions.cs index 1f4bce7..debd1d0 100644 --- a/backend/Books.Api/EventFlow/Extensions/ReadModelRegistrationExtensions.cs +++ b/backend/Books.Api/EventFlow/Extensions/ReadModelRegistrationExtensions.cs @@ -14,16 +14,19 @@ public static class ReadModelRegistrationExtensions { return options .UsePostgreSqlReadModel() - .RegisterServices( sr => sr.AddSingleton(new ReadModelSqlGenerator())); + .UsePostgreSqlReadModel() + .RegisterServices(sr => sr.AddSingleton(new ReadModelSqlGenerator())); } public static IServiceCollection AddRepositories(this IServiceCollection services) { // Register locators services.AddTransient(); + services.AddTransient(); // Register repositories services.AddScoped(); + services.AddScoped(); return services; } diff --git a/backend/Books.Api/EventFlow/ReadModels/ApiKeyReadModel.cs b/backend/Books.Api/EventFlow/ReadModels/ApiKeyReadModel.cs new file mode 100644 index 0000000..76089dd --- /dev/null +++ b/backend/Books.Api/EventFlow/ReadModels/ApiKeyReadModel.cs @@ -0,0 +1,74 @@ +using System.ComponentModel.DataAnnotations.Schema; +using Books.Api.Domain.ApiKeys; +using Books.Api.Domain.ApiKeys.Events; +using EventFlow.Aggregates; +using EventFlow.PostgreSql.ReadStores.Attributes; +using EventFlow.ReadStores; + +namespace Books.Api.EventFlow.ReadModels; + +[Table("apikey_read_models")] +public class ApiKeyReadModel : IReadModel, + IAmReadModelFor, + IAmReadModelFor +{ + [PostgreSqlReadModelIdentityColumn] + public string AggregateId { get; set; } = string.Empty; + + public DateTimeOffset CreateTime { get; set; } + public DateTimeOffset UpdatedTime { get; set; } + + [PostgreSqlReadModelVersionColumn] + public int LastAggregateSequenceNumber { get; set; } + + public string Name { get; set; } = string.Empty; + public string KeyHash { get; set; } = string.Empty; + public string CompanyId { get; set; } = string.Empty; + public string CreatedBy { get; set; } = string.Empty; + public bool IsActive { get; set; } = true; + public DateTimeOffset? RevokedTime { get; set; } + public string? RevokedBy { get; set; } + + public Task ApplyAsync( + IReadModelContext context, + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + var e = domainEvent.AggregateEvent; + AggregateId = domainEvent.AggregateIdentity.Value; + CreateTime = domainEvent.Timestamp; + UpdatedTime = domainEvent.Timestamp; + LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber; + Name = e.Name; + KeyHash = e.KeyHash; + CompanyId = e.CompanyId; + CreatedBy = e.CreatedBy; + IsActive = true; + return Task.CompletedTask; + } + + public Task ApplyAsync( + IReadModelContext context, + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + var e = domainEvent.AggregateEvent; + UpdatedTime = domainEvent.Timestamp; + LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber; + IsActive = false; + RevokedTime = domainEvent.Timestamp; + RevokedBy = e.RevokedBy; + return Task.CompletedTask; + } +} + +public class ApiKeyReadModelLocator : IReadModelLocator +{ + public IEnumerable GetReadModelIds(IDomainEvent domainEvent) + { + if (domainEvent.GetAggregateEvent() is IAggregateEvent) + { + yield return domainEvent.GetIdentity().Value; + } + } +} diff --git a/backend/Books.Api/EventFlow/Repositories/ApiKeyRepository.cs b/backend/Books.Api/EventFlow/Repositories/ApiKeyRepository.cs new file mode 100644 index 0000000..df16315 --- /dev/null +++ b/backend/Books.Api/EventFlow/Repositories/ApiKeyRepository.cs @@ -0,0 +1,55 @@ +using Dapper; +using Npgsql; + +namespace Books.Api.EventFlow.Repositories; + +public class ApiKeyRepository(NpgsqlDataSource dataSource) : IApiKeyRepository +{ + public async Task GetByIdForValidationAsync( + string apiKeyId, + CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + const string sql = """ + SELECT + aggregate_id AS ApiKeyId, + name AS Name, + key_hash AS KeyHash, + company_id AS CompanyId, + is_active AS IsActive + FROM apikey_read_models + WHERE aggregate_id = @ApiKeyId + AND is_active = true + """; + + return await connection.QuerySingleOrDefaultAsync( + sql, + new { ApiKeyId = apiKeyId }); + } + + public async Task> GetByCompanyIdAsync( + string companyId, + CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + const string sql = """ + SELECT + aggregate_id AS Id, + name AS Name, + company_id AS CompanyId, + created_by AS CreatedBy, + create_time AS CreatedAt, + is_active AS IsActive, + revoked_time AS RevokedAt, + revoked_by AS RevokedBy + FROM apikey_read_models + WHERE company_id = @CompanyId + ORDER BY create_time DESC + """; + + var result = await connection.QueryAsync(sql, new { CompanyId = companyId }); + return result.ToList(); + } +} diff --git a/backend/Books.Api/EventFlow/Repositories/IApiKeyRepository.cs b/backend/Books.Api/EventFlow/Repositories/IApiKeyRepository.cs new file mode 100644 index 0000000..0460d4c --- /dev/null +++ b/backend/Books.Api/EventFlow/Repositories/IApiKeyRepository.cs @@ -0,0 +1,24 @@ +namespace Books.Api.EventFlow.Repositories; + +public interface IApiKeyRepository +{ + Task GetByIdForValidationAsync(string apiKeyId, CancellationToken cancellationToken = default); + Task> GetByCompanyIdAsync(string companyId, CancellationToken cancellationToken = default); +} + +public record ApiKeyValidationDto( + string ApiKeyId, + string Name, + string KeyHash, + string CompanyId, + bool IsActive); + +public record ApiKeyDto( + string Id, + string Name, + string CompanyId, + string CreatedBy, + DateTime CreatedAt, + bool IsActive, + DateTime? RevokedAt, + string? RevokedBy); diff --git a/backend/Books.Api/Program.cs b/backend/Books.Api/Program.cs index 45b0a2b..fe7311c 100644 --- a/backend/Books.Api/Program.cs +++ b/backend/Books.Api/Program.cs @@ -23,6 +23,16 @@ if (app.Environment.IsDevelopment()) app.UseHttpsRedirection(); +// CORS must come before auth +app.UseCors(); + +// Authentication & Authorization +app.UseAuthentication(); +app.UseAuthorization(); + +// Map controllers (for AuthController) +app.MapControllers(); + // GraphQL endpoint app.UseGraphQL("/graphql"); diff --git a/backend/Books.Api/Properties/launchSettings.json b/backend/Books.Api/Properties/launchSettings.json index 39a6f26..98d91b4 100644 --- a/backend/Books.Api/Properties/launchSettings.json +++ b/backend/Books.Api/Properties/launchSettings.json @@ -14,7 +14,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": false, - "applicationUrl": "https://localhost:7141;http://localhost:5142", + "applicationUrl": "https://localhost:5001;http://localhost:5000", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/backend/Books.Api/Startup.cs b/backend/Books.Api/Startup.cs index 040562e..df44db2 100644 --- a/backend/Books.Api/Startup.cs +++ b/backend/Books.Api/Startup.cs @@ -1,3 +1,4 @@ +using Books.Api.Authentication; using Books.Api.EventFlow.Extensions; using Books.Api.EventFlow.Infrastructure; using Books.Api.GraphQL; @@ -14,6 +15,9 @@ using EventFlow.Subscribers; using GraphQL; using Hangfire; using Hangfire.PostgreSql; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Npgsql; namespace Books.Api; @@ -73,5 +77,96 @@ public static class Startup .AddDataLoader() .AddGraphTypes(typeof(BooksSchema).Assembly) .AddErrorInfoProvider(opt => opt.ExposeExceptionDetails = true)); + + // Memory cache for API key caching + services.AddMemoryCache(); + + // Controllers (for AuthController) + services.AddControllers(); + + // CORS + SetupCors(services, config); + + // Authentication & Authorization + SetupAuthentication(services, config); + } + + private static void SetupCors(IServiceCollection services, IConfiguration config) + { + var allowedOrigins = config.GetSection("Cors:AllowedOrigins").Get() + ?? ["http://localhost:3000"]; + + services.AddCors(options => + { + options.AddDefaultPolicy(builder => + { + builder.WithOrigins(allowedOrigins) + .AllowAnyMethod() + .AllowAnyHeader() + .AllowCredentials(); + }); + }); + } + + private static void SetupAuthentication(IServiceCollection services, IConfiguration config) + { + services.AddAuthorization(); + + var keycloakClientSecret = config["Keycloak:ClientSecret"]; + + // If Keycloak not configured, use cookie-only auth (for development) + if (string.IsNullOrEmpty(keycloakClientSecret)) + { + Console.WriteLine("[WARNING] Keycloak:ClientSecret not configured - OIDC authentication disabled"); + services.AddAuthentication(options => + { + options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; + }) + .AddCookie(options => + { + options.Cookie.Name = ".Books.Auth"; + options.Cookie.HttpOnly = true; + options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; + options.Cookie.SameSite = SameSiteMode.Lax; + options.ExpireTimeSpan = TimeSpan.FromDays(30); + options.SlidingExpiration = true; + }) + .AddScheme( + ApiKeyDefaults.AuthenticationScheme, _ => { }); + return; + } + + // Full authentication with OIDC + services.AddAuthentication(options => + { + options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme; + }) + .AddCookie(options => + { + options.Cookie.Name = ".Books.Auth"; + options.Cookie.HttpOnly = true; + options.Cookie.SecurePolicy = CookieSecurePolicy.Always; + options.Cookie.SameSite = SameSiteMode.None; + options.ExpireTimeSpan = TimeSpan.FromDays(30); + options.SlidingExpiration = true; + }) + .AddScheme( + ApiKeyDefaults.AuthenticationScheme, _ => { }) + .AddOpenIdConnect(options => + { + options.GetClaimsFromUserInfoEndpoint = true; + options.MetadataAddress = config["Keycloak:MetadataAddress"] + ?? "https://id.tatic.io/auth/realms/master/.well-known/openid-configuration"; + options.ClientId = config["Keycloak:ClientId"] ?? "books"; + options.SaveTokens = true; + options.ClientSecret = keycloakClientSecret; + options.ResponseType = "code"; + options.Scope.Add("openid"); + options.Scope.Add("email"); + options.Scope.Add("profile"); + options.CallbackPath = "/callback"; + options.UsePkce = true; + }); } } diff --git a/backend/Books.Api/appsettings.json b/backend/Books.Api/appsettings.json index ac4abfb..061891c 100644 --- a/backend/Books.Api/appsettings.json +++ b/backend/Books.Api/appsettings.json @@ -10,5 +10,15 @@ "AllowedHosts": "*", "ConnectionStrings": { "Default": "Host=localhost;Database=books;Username=postgres;Password=postgres;Include Error Detail=true" + }, + "Cors": { + "AllowedOrigins": [ + "http://localhost:3000" + ] + }, + "Keycloak": { + "MetadataAddress": "https://id.tatic.io/auth/realms/master/.well-known/openid-configuration", + "ClientId": "books", + "ClientSecret": "3fd1679b-4505-4e2b-9d19-0be19d4ae297" } } diff --git a/frontend/.env.development b/frontend/.env.development index 155c3f6..b14aa3b 100644 --- a/frontend/.env.development +++ b/frontend/.env.development @@ -1,2 +1 @@ -# Development environment defaults -VITE_GRAPHQL_ENDPOINT=http://localhost:5000/graphql +VITE_GRAPHQL_ENDPOINT=https://localhost:5001/graphql diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f5d1dbc..fc3f2d7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,14 +2,17 @@ import { BrowserRouter } from 'react-router-dom'; import { App as AntApp } from 'antd'; import AppRoutes from './routes'; import AppLayout from './components/layout/AppLayout'; +import ProtectedRoute from './components/auth/ProtectedRoute'; function App() { return ( - - - + + + + + ); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 401d79e..2efc594 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -4,22 +4,11 @@ import { QueryClient } from '@tanstack/react-query'; // GraphQL endpoint - configure based on environment const GRAPHQL_ENDPOINT = import.meta.env.VITE_GRAPHQL_ENDPOINT || 'http://localhost:5000/graphql'; -// Create GraphQL client +// Create GraphQL client with cookie-based authentication export const graphqlClient = new GraphQLClient(GRAPHQL_ENDPOINT, { - headers: { - // Add auth headers here when authentication is implemented - // 'Authorization': `Bearer ${token}`, - }, + credentials: 'include', // Send cookies with requests }); -// Configure headers dynamically (for auth tokens, etc.) -export const setAuthHeader = (token: string) => { - graphqlClient.setHeader('Authorization', `Bearer ${token}`); -}; - -export const removeAuthHeader = () => { - graphqlClient.setHeader('Authorization', ''); -}; // Create TanStack Query client with default options export const queryClient = new QueryClient({ diff --git a/frontend/src/components/auth/ProtectedRoute.tsx b/frontend/src/components/auth/ProtectedRoute.tsx new file mode 100644 index 0000000..2735278 --- /dev/null +++ b/frontend/src/components/auth/ProtectedRoute.tsx @@ -0,0 +1,64 @@ +import { useEffect, type ReactNode } from 'react'; +import { Spin } from 'antd'; +import { useAuthStore } from '@/stores/authStore'; + +interface ProtectedRouteProps { + children: ReactNode; +} + +/** + * Wrapper component that ensures the user is authenticated + * Shows loading spinner while checking auth status + * Redirects to login if not authenticated + */ +export function ProtectedRoute({ children }: ProtectedRouteProps) { + const { isAuthenticated, isLoading, login, refreshUser } = useAuthStore(); + + // Check auth on mount + useEffect(() => { + refreshUser(); + }, [refreshUser]); + + // Show loading spinner while checking auth + if (isLoading) { + return ( +
+ + Logger ind... +
+ ); + } + + // Redirect to login if not authenticated + if (!isAuthenticated) { + login(); + return ( +
+ + Omdirigerer til login... +
+ ); + } + + return <>{children}; +} + +export default ProtectedRoute; diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx index 49654d0..9890f81 100644 --- a/frontend/src/components/layout/Header.tsx +++ b/frontend/src/components/layout/Header.tsx @@ -9,11 +9,14 @@ import { import type { MenuProps } from 'antd'; import CompanySwitcher from './CompanySwitcher'; import FiscalYearSelector from './FiscalYearSelector'; +import { useAuthStore } from '@/stores/authStore'; const { Header: AntHeader } = Layout; const { Text } = Typography; export default function Header() { + const { user, logout } = useAuthStore(); + const userMenuItems: MenuProps['items'] = [ { key: 'profile', @@ -39,8 +42,7 @@ export default function Header() { const handleUserMenuClick: MenuProps['onClick'] = ({ key }) => { switch (key) { case 'logout': - // Handle logout - console.log('Logout clicked'); + logout(); break; case 'settings': // Navigate to settings @@ -103,7 +105,7 @@ export default function Header() { style={{ backgroundColor: '#1677ff' }} /> - Bruger + {user?.name || user?.email || 'Bruger'} diff --git a/frontend/src/services/authService.ts b/frontend/src/services/authService.ts new file mode 100644 index 0000000..4ef2beb --- /dev/null +++ b/frontend/src/services/authService.ts @@ -0,0 +1,82 @@ +// API URL - extract base from GraphQL endpoint +const API_URL = (import.meta.env.VITE_GRAPHQL_ENDPOINT || 'http://localhost:5000/graphql').replace('/graphql', ''); + +export interface UserContext { + id: string | null; + email: string | null; + name: string | null; + companyId: string | null; + isApiKey: boolean; +} + +class AuthService { + private userContext: UserContext | null = null; + + /** + * Fetch user profile from the backend + * Returns null if not authenticated + */ + async refreshUserContext(): Promise { + try { + const response = await fetch(`${API_URL}/api/profile`, { + credentials: 'include', + }); + + if (response.ok) { + this.userContext = await response.json(); + return this.userContext; + } + + // 401/403 means not authenticated + this.userContext = null; + return null; + } catch (error) { + console.error('Failed to fetch user profile:', error); + this.userContext = null; + return null; + } + } + + /** + * Redirect to login page + * The backend will handle OIDC authentication and redirect back + */ + login(returnUrl?: string): void { + const url = returnUrl || window.location.href; + window.location.href = `${API_URL}/api/login?returnUrl=${encodeURIComponent(url)}`; + } + + /** + * Logout and clear session + */ + async logout(): Promise { + try { + await fetch(`${API_URL}/api/logout`, { + method: 'GET', + credentials: 'include', + }); + } catch (error) { + console.error('Logout error:', error); + } + + this.userContext = null; + window.location.reload(); + } + + /** + * Get the current user context + */ + get user(): UserContext | null { + return this.userContext; + } + + /** + * Check if user is authenticated + */ + get isAuthenticated(): boolean { + return this.userContext !== null; + } +} + +// Singleton instance +export const authService = new AuthService(); diff --git a/frontend/src/stores/authStore.ts b/frontend/src/stores/authStore.ts new file mode 100644 index 0000000..73a2dba --- /dev/null +++ b/frontend/src/stores/authStore.ts @@ -0,0 +1,61 @@ +import { create } from 'zustand'; +import { authService, type UserContext } from '@/services/authService'; + +interface AuthState { + // User context from backend + user: UserContext | null; + // Loading state for initial auth check + isLoading: boolean; + // Whether user is authenticated + isAuthenticated: boolean; + // Error message if auth failed + error: string | null; + + // Actions + login: (returnUrl?: string) => void; + logout: () => Promise; + refreshUser: () => Promise; + clearError: () => void; +} + +export const useAuthStore = create((set) => ({ + user: null, + isLoading: true, + isAuthenticated: false, + error: null, + + login: (returnUrl?: string) => { + authService.login(returnUrl); + }, + + logout: async () => { + await authService.logout(); + set({ user: null, isAuthenticated: false }); + }, + + refreshUser: async () => { + set({ isLoading: true, error: null }); + try { + const user = await authService.refreshUserContext(); + set({ + user, + isAuthenticated: user !== null, + isLoading: false, + }); + } catch (error) { + set({ + user: null, + isAuthenticated: false, + isLoading: false, + error: error instanceof Error ? error.message : 'Authentication failed', + }); + } + }, + + clearError: () => set({ error: null }), +})); + +// Selector hooks for convenience +export const useUser = () => useAuthStore((state) => state.user); +export const useIsAuthenticated = () => useAuthStore((state) => state.isAuthenticated); +export const useAuthLoading = () => useAuthStore((state) => state.isLoading); diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo index 6b57a9d..916a2ee 100644 --- a/frontend/tsconfig.tsbuildinfo +++ b/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/routes.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/components/layout/applayout.tsx","./src/components/layout/companyswitcher.tsx","./src/components/layout/fiscalyearselector.tsx","./src/components/layout/header.tsx","./src/components/layout/sidebar.tsx","./src/components/modals/closefiscalyearwizard.tsx","./src/components/modals/createfiscalyearmodal.tsx","./src/components/simple-booking/accountquickpicker.tsx","./src/components/simple-booking/banktransactioncard.tsx","./src/components/simple-booking/quickbookmodal.tsx","./src/components/simple-booking/splitbookmodal.tsx","./src/components/simple-booking/index.ts","./src/components/tables/datatable.tsx","./src/hooks/usecompany.ts","./src/hooks/usedatatable.ts","./src/hooks/useperiod.ts","./src/lib/accounting.ts","./src/lib/fiscalyear.ts","./src/lib/formatters.ts","./src/lib/periods.ts","./src/lib/vatcalculation.ts","./src/lib/vatcodes.ts","./src/pages/bankafstemning.tsx","./src/pages/dashboard.tsx","./src/pages/hurtigbogforing.tsx","./src/pages/kassekladde.tsx","./src/pages/kontooversigt.tsx","./src/pages/loenforstaelse.tsx","./src/pages/momsindberetning.tsx","./src/pages/settings.tsx","./src/stores/companystore.ts","./src/stores/periodstore.ts","./src/stores/reconciliationstore.ts","./src/stores/simplebookingstore.ts","./src/stores/uistore.ts","./src/styles/theme.ts","./src/types/accounting.ts","./src/types/api.ts","./src/types/periods.ts","./src/types/ui.ts","./src/types/vat.ts"],"errors":true,"version":"5.6.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/main.tsx","./src/routes.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/components/auth/protectedroute.tsx","./src/components/layout/applayout.tsx","./src/components/layout/companyswitcher.tsx","./src/components/layout/fiscalyearselector.tsx","./src/components/layout/header.tsx","./src/components/layout/sidebar.tsx","./src/components/modals/closefiscalyearwizard.tsx","./src/components/modals/createfiscalyearmodal.tsx","./src/components/simple-booking/accountquickpicker.tsx","./src/components/simple-booking/banktransactioncard.tsx","./src/components/simple-booking/quickbookmodal.tsx","./src/components/simple-booking/splitbookmodal.tsx","./src/components/simple-booking/index.ts","./src/components/tables/datatable.tsx","./src/hooks/usecompany.ts","./src/hooks/usedatatable.ts","./src/hooks/useperiod.ts","./src/lib/accounting.ts","./src/lib/fiscalyear.ts","./src/lib/formatters.ts","./src/lib/periods.ts","./src/lib/vatcalculation.ts","./src/lib/vatcodes.ts","./src/pages/bankafstemning.tsx","./src/pages/dashboard.tsx","./src/pages/hurtigbogforing.tsx","./src/pages/kassekladde.tsx","./src/pages/kontooversigt.tsx","./src/pages/loenforstaelse.tsx","./src/pages/momsindberetning.tsx","./src/pages/settings.tsx","./src/services/authservice.ts","./src/stores/authstore.ts","./src/stores/companystore.ts","./src/stores/periodstore.ts","./src/stores/reconciliationstore.ts","./src/stores/simplebookingstore.ts","./src/stores/uistore.ts","./src/styles/theme.ts","./src/types/accounting.ts","./src/types/api.ts","./src/types/periods.ts","./src/types/ui.ts","./src/types/vat.ts"],"errors":true,"version":"5.6.3"} \ No newline at end of file