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:
Nicolaj Hartmann 2026-01-18 11:49:29 +01:00
parent c4a27f0bac
commit 926085eeab
28 changed files with 849 additions and 24 deletions

View 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);
}

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

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

View file

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

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

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

View 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);
}
}

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

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

View file

@ -0,0 +1,8 @@
using EventFlow.Core;
namespace Books.Api.Domain.ApiKeys;
public class ApiKeyId : Identity<ApiKeyId>
{
public ApiKeyId(string value) : base(value) { }
}

View file

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

View file

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

View file

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

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

View 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();
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,2 +1 @@
# Development environment defaults VITE_GRAPHQL_ENDPOINT=https://localhost:5001/graphql
VITE_GRAPHQL_ENDPOINT=http://localhost:5000/graphql

View file

@ -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>
<AppLayout> <ProtectedRoute>
<AppRoutes /> <AppLayout>
</AppLayout> <AppRoutes />
</AppLayout>
</ProtectedRoute>
</BrowserRouter> </BrowserRouter>
</AntApp> </AntApp>
); );

View file

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

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

View file

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

View 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();

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

View file

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