Add OpenID Connect + API Key authentication
Backend: - Cookie + OIDC + API Key authentication schemes - ApiKeyAuthenticationHandler with SHA-256 validation and 24h cache - AuthController with login/logout/profile endpoints - API Key domain model (EventFlow aggregate, events, commands) - ApiKeyReadModel and repository for key validation - Database migration 002_ApiKeys.sql - CORS configuration for frontend Frontend: - authService.ts for login/logout/profile API calls - authStore.ts (Zustand) for user context state - ProtectedRoute component for route guards - Header updated with user display and logout - GraphQL client with credentials: include 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
c4a27f0bac
commit
926085eeab
28 changed files with 849 additions and 24 deletions
114
backend/Books.Api/Authentication/ApiKeyAuthenticationHandler.cs
Normal file
114
backend/Books.Api/Authentication/ApiKeyAuthenticationHandler.cs
Normal file
|
|
@ -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<ApiKeyAuthenticationOptions> options,
|
||||||
|
ILoggerFactory logger,
|
||||||
|
UrlEncoder encoder,
|
||||||
|
IMemoryCache cache,
|
||||||
|
IServiceProvider serviceProvider)
|
||||||
|
: AuthenticationHandler<ApiKeyAuthenticationOptions>(options, logger, encoder)
|
||||||
|
{
|
||||||
|
protected override async Task<AuthenticateResult> 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<IApiKeyRepository>();
|
||||||
|
|
||||||
|
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<Claim>
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
7
backend/Books.Api/Authentication/ApiKeyDefaults.cs
Normal file
7
backend/Books.Api/Authentication/ApiKeyDefaults.cs
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
namespace Books.Api.Authentication;
|
||||||
|
|
||||||
|
public static class ApiKeyDefaults
|
||||||
|
{
|
||||||
|
public const string AuthenticationScheme = "ApiKey";
|
||||||
|
public const string HeaderName = "x-api-key";
|
||||||
|
}
|
||||||
34
backend/Books.Api/Authentication/UserExtensions.cs
Normal file
34
backend/Books.Api/Authentication/UserExtensions.cs
Normal file
|
|
@ -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);
|
||||||
|
|
@ -37,6 +37,9 @@
|
||||||
|
|
||||||
<!-- DI Decoration -->
|
<!-- DI Decoration -->
|
||||||
<PackageReference Include="Scrutor" Version="5.0.2" />
|
<PackageReference Include="Scrutor" Version="5.0.2" />
|
||||||
|
|
||||||
|
<!-- Authentication -->
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
|
||||||
30
backend/Books.Api/Commands/ApiKeys/ApiKeyCommandHandlers.cs
Normal file
30
backend/Books.Api/Commands/ApiKeys/ApiKeyCommandHandlers.cs
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
using Books.Api.Domain.ApiKeys;
|
||||||
|
using EventFlow.Commands;
|
||||||
|
|
||||||
|
namespace Books.Api.Commands.ApiKeys;
|
||||||
|
|
||||||
|
public class CreateApiKeyCommandHandler
|
||||||
|
: CommandHandler<ApiKeyAggregate, ApiKeyId, CreateApiKeyCommand>
|
||||||
|
{
|
||||||
|
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<ApiKeyAggregate, ApiKeyId, RevokeApiKeyCommand>
|
||||||
|
{
|
||||||
|
public override Task ExecuteAsync(
|
||||||
|
ApiKeyAggregate aggregate,
|
||||||
|
RevokeApiKeyCommand command,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
aggregate.Revoke(command.RevokedBy);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
backend/Books.Api/Commands/ApiKeys/ApiKeyCommands.cs
Normal file
24
backend/Books.Api/Commands/ApiKeys/ApiKeyCommands.cs
Normal file
|
|
@ -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<ApiKeyAggregate, ApiKeyId>(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<ApiKeyAggregate, ApiKeyId>(aggregateId)
|
||||||
|
{
|
||||||
|
public string RevokedBy { get; } = revokedBy;
|
||||||
|
}
|
||||||
41
backend/Books.Api/Controllers/AuthController.cs
Normal file
41
backend/Books.Api/Controllers/AuthController.cs
Normal file
|
|
@ -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<IActionResult> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
backend/Books.Api/Database/Migrations/002_ApiKeys.sql
Normal file
21
backend/Books.Api/Database/Migrations/002_ApiKeys.sql
Normal file
|
|
@ -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;
|
||||||
49
backend/Books.Api/Domain/ApiKeys/ApiKeyAggregate.cs
Normal file
49
backend/Books.Api/Domain/ApiKeys/ApiKeyAggregate.cs
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
using Books.Api.Domain.ApiKeys.Events;
|
||||||
|
using EventFlow.Aggregates;
|
||||||
|
|
||||||
|
namespace Books.Api.Domain.ApiKeys;
|
||||||
|
|
||||||
|
public class ApiKeyAggregate : AggregateRoot<ApiKeyAggregate, ApiKeyId>,
|
||||||
|
IEmit<ApiKeyCreatedEvent>,
|
||||||
|
IEmit<ApiKeyRevokedEvent>
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
8
backend/Books.Api/Domain/ApiKeys/ApiKeyId.cs
Normal file
8
backend/Books.Api/Domain/ApiKeys/ApiKeyId.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
using EventFlow.Core;
|
||||||
|
|
||||||
|
namespace Books.Api.Domain.ApiKeys;
|
||||||
|
|
||||||
|
public class ApiKeyId : Identity<ApiKeyId>
|
||||||
|
{
|
||||||
|
public ApiKeyId(string value) : base(value) { }
|
||||||
|
}
|
||||||
|
|
@ -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<ApiKeyAggregate, ApiKeyId>
|
||||||
|
{
|
||||||
|
public string Name { get; } = name;
|
||||||
|
public string KeyHash { get; } = keyHash;
|
||||||
|
public string CompanyId { get; } = companyId;
|
||||||
|
public string CreatedBy { get; } = createdBy;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
using EventFlow.Aggregates;
|
||||||
|
|
||||||
|
namespace Books.Api.Domain.ApiKeys.Events;
|
||||||
|
|
||||||
|
public class ApiKeyRevokedEvent(string revokedBy) : AggregateEvent<ApiKeyAggregate, ApiKeyId>
|
||||||
|
{
|
||||||
|
public string RevokedBy { get; } = revokedBy;
|
||||||
|
}
|
||||||
|
|
@ -14,16 +14,19 @@ public static class ReadModelRegistrationExtensions
|
||||||
{
|
{
|
||||||
return options
|
return options
|
||||||
.UsePostgreSqlReadModel<CompanyReadModel, CompanyReadModelLocator>()
|
.UsePostgreSqlReadModel<CompanyReadModel, CompanyReadModelLocator>()
|
||||||
.RegisterServices( sr => sr.AddSingleton<IReadModelSqlGenerator>(new ReadModelSqlGenerator()));
|
.UsePostgreSqlReadModel<ApiKeyReadModel, ApiKeyReadModelLocator>()
|
||||||
|
.RegisterServices(sr => sr.AddSingleton<IReadModelSqlGenerator>(new ReadModelSqlGenerator()));
|
||||||
}
|
}
|
||||||
|
|
||||||
public static IServiceCollection AddRepositories(this IServiceCollection services)
|
public static IServiceCollection AddRepositories(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
// Register locators
|
// Register locators
|
||||||
services.AddTransient<CompanyReadModelLocator>();
|
services.AddTransient<CompanyReadModelLocator>();
|
||||||
|
services.AddTransient<ApiKeyReadModelLocator>();
|
||||||
|
|
||||||
// Register repositories
|
// Register repositories
|
||||||
services.AddScoped<ICompanyRepository, CompanyRepository>();
|
services.AddScoped<ICompanyRepository, CompanyRepository>();
|
||||||
|
services.AddScoped<IApiKeyRepository, ApiKeyRepository>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
74
backend/Books.Api/EventFlow/ReadModels/ApiKeyReadModel.cs
Normal file
74
backend/Books.Api/EventFlow/ReadModels/ApiKeyReadModel.cs
Normal file
|
|
@ -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<ApiKeyAggregate, ApiKeyId, ApiKeyCreatedEvent>,
|
||||||
|
IAmReadModelFor<ApiKeyAggregate, ApiKeyId, ApiKeyRevokedEvent>
|
||||||
|
{
|
||||||
|
[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<ApiKeyAggregate, ApiKeyId, ApiKeyCreatedEvent> 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<ApiKeyAggregate, ApiKeyId, ApiKeyRevokedEvent> 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<string> GetReadModelIds(IDomainEvent domainEvent)
|
||||||
|
{
|
||||||
|
if (domainEvent.GetAggregateEvent() is IAggregateEvent<ApiKeyAggregate, ApiKeyId>)
|
||||||
|
{
|
||||||
|
yield return domainEvent.GetIdentity().Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
55
backend/Books.Api/EventFlow/Repositories/ApiKeyRepository.cs
Normal file
55
backend/Books.Api/EventFlow/Repositories/ApiKeyRepository.cs
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
using Dapper;
|
||||||
|
using Npgsql;
|
||||||
|
|
||||||
|
namespace Books.Api.EventFlow.Repositories;
|
||||||
|
|
||||||
|
public class ApiKeyRepository(NpgsqlDataSource dataSource) : IApiKeyRepository
|
||||||
|
{
|
||||||
|
public async Task<ApiKeyValidationDto?> 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<ApiKeyValidationDto>(
|
||||||
|
sql,
|
||||||
|
new { ApiKeyId = apiKeyId });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<ApiKeyDto>> 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<ApiKeyDto>(sql, new { CompanyId = companyId });
|
||||||
|
return result.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
namespace Books.Api.EventFlow.Repositories;
|
||||||
|
|
||||||
|
public interface IApiKeyRepository
|
||||||
|
{
|
||||||
|
Task<ApiKeyValidationDto?> GetByIdForValidationAsync(string apiKeyId, CancellationToken cancellationToken = default);
|
||||||
|
Task<IReadOnlyList<ApiKeyDto>> 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);
|
||||||
|
|
@ -23,6 +23,16 @@ if (app.Environment.IsDevelopment())
|
||||||
|
|
||||||
app.UseHttpsRedirection();
|
app.UseHttpsRedirection();
|
||||||
|
|
||||||
|
// CORS must come before auth
|
||||||
|
app.UseCors();
|
||||||
|
|
||||||
|
// Authentication & Authorization
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
|
||||||
|
// Map controllers (for AuthController)
|
||||||
|
app.MapControllers();
|
||||||
|
|
||||||
// GraphQL endpoint
|
// GraphQL endpoint
|
||||||
app.UseGraphQL<BooksSchema>("/graphql");
|
app.UseGraphQL<BooksSchema>("/graphql");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
"commandName": "Project",
|
"commandName": "Project",
|
||||||
"dotnetRunMessages": true,
|
"dotnetRunMessages": true,
|
||||||
"launchBrowser": false,
|
"launchBrowser": false,
|
||||||
"applicationUrl": "https://localhost:7141;http://localhost:5142",
|
"applicationUrl": "https://localhost:5001;http://localhost:5000",
|
||||||
"environmentVariables": {
|
"environmentVariables": {
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using Books.Api.Authentication;
|
||||||
using Books.Api.EventFlow.Extensions;
|
using Books.Api.EventFlow.Extensions;
|
||||||
using Books.Api.EventFlow.Infrastructure;
|
using Books.Api.EventFlow.Infrastructure;
|
||||||
using Books.Api.GraphQL;
|
using Books.Api.GraphQL;
|
||||||
|
|
@ -14,6 +15,9 @@ using EventFlow.Subscribers;
|
||||||
using GraphQL;
|
using GraphQL;
|
||||||
using Hangfire;
|
using Hangfire;
|
||||||
using Hangfire.PostgreSql;
|
using Hangfire.PostgreSql;
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
|
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
|
|
||||||
namespace Books.Api;
|
namespace Books.Api;
|
||||||
|
|
@ -73,5 +77,96 @@ public static class Startup
|
||||||
.AddDataLoader()
|
.AddDataLoader()
|
||||||
.AddGraphTypes(typeof(BooksSchema).Assembly)
|
.AddGraphTypes(typeof(BooksSchema).Assembly)
|
||||||
.AddErrorInfoProvider(opt => opt.ExposeExceptionDetails = true));
|
.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<string[]>()
|
||||||
|
?? ["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<ApiKeyAuthenticationOptions, ApiKeyAuthenticationHandler>(
|
||||||
|
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<ApiKeyAuthenticationOptions, ApiKeyAuthenticationHandler>(
|
||||||
|
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;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,5 +10,15 @@
|
||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*",
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"Default": "Host=localhost;Database=books;Username=postgres;Password=postgres;Include Error Detail=true"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1 @@
|
||||||
# Development environment defaults
|
VITE_GRAPHQL_ENDPOINT=https://localhost:5001/graphql
|
||||||
VITE_GRAPHQL_ENDPOINT=http://localhost:5000/graphql
|
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,17 @@ import { BrowserRouter } from 'react-router-dom';
|
||||||
import { App as AntApp } from 'antd';
|
import { App as AntApp } from 'antd';
|
||||||
import AppRoutes from './routes';
|
import AppRoutes from './routes';
|
||||||
import AppLayout from './components/layout/AppLayout';
|
import AppLayout from './components/layout/AppLayout';
|
||||||
|
import ProtectedRoute from './components/auth/ProtectedRoute';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<AntApp>
|
<AntApp>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
<ProtectedRoute>
|
||||||
<AppLayout>
|
<AppLayout>
|
||||||
<AppRoutes />
|
<AppRoutes />
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
|
</ProtectedRoute>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</AntApp>
|
</AntApp>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -4,22 +4,11 @@ import { QueryClient } from '@tanstack/react-query';
|
||||||
// GraphQL endpoint - configure based on environment
|
// GraphQL endpoint - configure based on environment
|
||||||
const GRAPHQL_ENDPOINT = import.meta.env.VITE_GRAPHQL_ENDPOINT || 'http://localhost:5000/graphql';
|
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, {
|
export const graphqlClient = new GraphQLClient(GRAPHQL_ENDPOINT, {
|
||||||
headers: {
|
credentials: 'include', // Send cookies with requests
|
||||||
// Add auth headers here when authentication is implemented
|
|
||||||
// 'Authorization': `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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
|
// Create TanStack Query client with default options
|
||||||
export const queryClient = new QueryClient({
|
export const queryClient = new QueryClient({
|
||||||
|
|
|
||||||
64
frontend/src/components/auth/ProtectedRoute.tsx
Normal file
64
frontend/src/components/auth/ProtectedRoute.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: '100vh',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Spin size="large" />
|
||||||
|
<span style={{ color: '#666' }}>Logger ind...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to login if not authenticated
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
login();
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: '100vh',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Spin size="large" />
|
||||||
|
<span style={{ color: '#666' }}>Omdirigerer til login...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProtectedRoute;
|
||||||
|
|
@ -9,11 +9,14 @@ import {
|
||||||
import type { MenuProps } from 'antd';
|
import type { MenuProps } from 'antd';
|
||||||
import CompanySwitcher from './CompanySwitcher';
|
import CompanySwitcher from './CompanySwitcher';
|
||||||
import FiscalYearSelector from './FiscalYearSelector';
|
import FiscalYearSelector from './FiscalYearSelector';
|
||||||
|
import { useAuthStore } from '@/stores/authStore';
|
||||||
|
|
||||||
const { Header: AntHeader } = Layout;
|
const { Header: AntHeader } = Layout;
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
|
const { user, logout } = useAuthStore();
|
||||||
|
|
||||||
const userMenuItems: MenuProps['items'] = [
|
const userMenuItems: MenuProps['items'] = [
|
||||||
{
|
{
|
||||||
key: 'profile',
|
key: 'profile',
|
||||||
|
|
@ -39,8 +42,7 @@ export default function Header() {
|
||||||
const handleUserMenuClick: MenuProps['onClick'] = ({ key }) => {
|
const handleUserMenuClick: MenuProps['onClick'] = ({ key }) => {
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case 'logout':
|
case 'logout':
|
||||||
// Handle logout
|
logout();
|
||||||
console.log('Logout clicked');
|
|
||||||
break;
|
break;
|
||||||
case 'settings':
|
case 'settings':
|
||||||
// Navigate to settings
|
// Navigate to settings
|
||||||
|
|
@ -103,7 +105,7 @@ export default function Header() {
|
||||||
style={{ backgroundColor: '#1677ff' }}
|
style={{ backgroundColor: '#1677ff' }}
|
||||||
/>
|
/>
|
||||||
<Text style={{ maxWidth: 120 }} ellipsis>
|
<Text style={{ maxWidth: 120 }} ellipsis>
|
||||||
Bruger
|
{user?.name || user?.email || 'Bruger'}
|
||||||
</Text>
|
</Text>
|
||||||
</Space>
|
</Space>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
|
|
|
||||||
82
frontend/src/services/authService.ts
Normal file
82
frontend/src/services/authService.ts
Normal file
|
|
@ -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<UserContext | null> {
|
||||||
|
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<void> {
|
||||||
|
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();
|
||||||
61
frontend/src/stores/authStore.ts
Normal file
61
frontend/src/stores/authStore.ts
Normal file
|
|
@ -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<void>;
|
||||||
|
refreshUser: () => Promise<void>;
|
||||||
|
clearError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>((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);
|
||||||
|
|
@ -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"}
|
{"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"}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue