added auth with a simple UI

This commit is contained in:
2026-03-09 23:29:04 +01:00
parent c8347ac96b
commit 82ac241129
123 changed files with 3419 additions and 93 deletions

View File

@@ -0,0 +1,10 @@
using OED.Api.Core.Models.Eve;
namespace OED.Api.Core.Interfaces.Services;
public interface ISessionService
{
Task<string> CreateAsync(EveSession session);
Task<EveSession?> GetAsync(string sessionId);
Task DeleteAsync(string sessionId);
}

View File

@@ -0,0 +1,9 @@
namespace OED.Api.Core.Interfaces.Services;
public interface ITokenStore
{
Task SetAccessTokenAsync(long characterId, string token, TimeSpan expiry);
Task<string?> GetAccessTokenAsync(long characterId);
Task SetRefreshTokenAsync(long characterId, string token);
Task<string?> GetRefreshTokenAsync(long characterId);
}

View File

@@ -0,0 +1,11 @@
namespace OED.Api.Core.Models.Eve;
public record EveCharacter
{
public long CharacterId { get; init; }
public string CharacterName { get; init; } = string.Empty;
public string[] Scopes { get; init; } = [];
public string AccessToken { get; init; } = string.Empty;
public string RefreshToken { get; init; } = string.Empty;
public DateTimeOffset AccessTokenExpiry { get; init; }
}

View File

@@ -0,0 +1,8 @@
namespace OED.Api.Core.Models.Eve;
public record EveSession
{
public long CharacterId { get; init; }
public string CharacterName { get; init; } = string.Empty;
public string[] Scopes { get; init; } = [];
}

View File

@@ -0,0 +1,148 @@
using OED.Api.Core.Interfaces.Services;
using OED.Api.Core.Models.Eve;
using OED.Api.Infrastructure.Auth;
using OED.Api.Infrastructure.Esi;
namespace OED.Api.Endpoints;
public static class AuthEndpoints
{
public static void MapAuthEndpoints(this WebApplication app)
{
app.MapGet("/auth/login", Login);
app.MapGet("/auth/callback", Callback);
app.MapGet("/auth/me", Me);
app.MapPost("/auth/logout", Logout);
}
private static async Task<IResult> Login(IConfiguration config, IStateStore stateStore)
{
var clientId = config["Eve:ClientId"]!;
var redirectUri = Uri.EscapeDataString(config["Eve:CallbackUrl"]!);
var scopes = Uri.EscapeDataString("esi-markets.structure_markets.v1");
var state = await stateStore.GenerateAsync();
var url = "https://login.eveonline.com/v2/oauth/authorize" +
$"?response_type=code" +
$"&redirect_uri={redirectUri}" +
$"&client_id={clientId}" +
$"&scope={scopes}" +
$"&state={state}";
return Results.Redirect(url);
}
private static async Task<IResult> Callback(
HttpContext context,
string code,
string state,
IConfiguration config,
IHttpClientFactory httpClientFactory,
EveJwtValidator jwtValidator,
ISessionService sessionService,
ITokenStore tokenStore,
IStateStore stateStore,
ILoggerFactory loggerFactory)
{
var logger = loggerFactory.CreateLogger("AuthEndpoints");
logger.LogInformation("Callback hit. Code: {Code}, State: {State}", code, state);
var frontendUrl = config["Frontend:Url"]!;
// Validate and immediately consume the state — prevents replay
if (!await stateStore.ValidateAndConsumeAsync(state))
{
logger.LogWarning("State validation failed for state: {State}", state);
return Results.Redirect($"{frontendUrl}/?error=invalid_state");
}
var clientId = config["Eve:ClientId"]!;
var secretKey = config["Eve:SecretKey"]!;
var credentials = Convert.ToBase64String(
System.Text.Encoding.UTF8.GetBytes($"{clientId}:{secretKey}")
);
var client = httpClientFactory.CreateClient();
var tokenRequest = new HttpRequestMessage(HttpMethod.Post, "https://login.eveonline.com/v2/oauth/token");
tokenRequest.Headers.Add("Authorization", $"Basic {credentials}");
tokenRequest.Content = new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "authorization_code",
["code"] = code
});
var tokenResponse = await client.SendAsync(tokenRequest);
if (!tokenResponse.IsSuccessStatusCode)
return Results.Redirect($"{frontendUrl}/?error=token_exchange_failed");
var tokens = await tokenResponse.Content.ReadFromJsonAsync<EsiTokenResponse>()
?? throw new InvalidOperationException("Empty response from EVE SSO");
// Validate JWT — throws if tampered, expired, or wrong issuer
var jwt = await jwtValidator.ValidateAsync(tokens.AccessToken, clientId);
var sub = jwt.Subject; // "CHARACTER:EVE:123456789"
var characterId = long.Parse(sub.Split(':')[2]);
var characterName = jwt.Claims.First(c => c.Type == "name").Value;
var scopes = jwt.Claims
.Where(c => c.Type == "scp")
.Select(c => c.Value)
.ToArray();
await tokenStore.SetAccessTokenAsync(
characterId,
tokens.AccessToken,
TimeSpan.FromSeconds(tokens.ExpiresIn - 30)
);
await tokenStore.SetRefreshTokenAsync(characterId, tokens.RefreshToken);
var session = new EveSession
{
CharacterId = characterId,
CharacterName = characterName,
Scopes = scopes
};
var sessionId = await sessionService.CreateAsync(session);
context.Response.Cookies.Append("session", sessionId, new CookieOptions
{
HttpOnly = true,
Secure = false,
SameSite = SameSiteMode.Lax,
Domain = "localhost",
Expires = DateTimeOffset.UtcNow.AddDays(7)
});
logger.LogInformation("Session created: {SessionId}, redirecting to {Url}", sessionId, frontendUrl);
return Results.Redirect($"{frontendUrl}/auth/callback?success=true");
}
private static IResult Me(HttpContext context)
{
var session = context.Items["Session"] as EveSession;
if (session is null) return Results.Unauthorized();
return Results.Ok(new
{
session.CharacterId,
session.CharacterName,
session.Scopes
});
}
private static async Task<IResult> Logout(
HttpContext context,
ISessionService sessionService)
{
if (context.Request.Cookies.TryGetValue("session", out var sessionId))
{
await sessionService.DeleteAsync(sessionId);
context.Response.Cookies.Delete("session");
}
return Results.Ok();
}
}

View File

@@ -0,0 +1,36 @@
using System.IdentityModel.Tokens.Jwt;
using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;
namespace OED.Api.Infrastructure.Auth;
public class EveJwtValidator
{
private const string MetadataAddress = "https://login.eveonline.com/.well-known/oauth-authorization-server";
private readonly ConfigurationManager<OpenIdConnectConfiguration> _configManager = new(
MetadataAddress,
new OpenIdConnectConfigurationRetriever()
);
public async Task<JwtSecurityToken> ValidateAsync(string token, string clientId)
{
var config = await _configManager.GetConfigurationAsync();
var parameters = new TokenValidationParameters
{
ValidIssuer = "https://login.eveonline.com",
ValidAudiences = [clientId, "EVE Online"],
IssuerSigningKeys = config.SigningKeys,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidateAudience = true,
ValidateIssuer = true,
};
var handler = new JwtSecurityTokenHandler();
handler.ValidateToken(token, parameters, out var validatedToken);
return (JwtSecurityToken)validatedToken;
}
}

View File

@@ -0,0 +1,23 @@
using OED.Api.Core.Interfaces.Services;
namespace OED.Api.Infrastructure.Auth;
// Runs on every request — reads the session cookie and stamps CharacterId onto HttpContext.Items
public class SessionMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext context, ISessionService sessionService)
{
if (context.Request.Cookies.TryGetValue("session", out var sessionId))
{
var session = await sessionService.GetAsync(sessionId);
if (session is not null)
{
context.Items["CharacterId"] = session.CharacterId;
context.Items["CharacterName"] = session.CharacterName;
context.Items["Session"] = session;
}
}
await next(context);
}
}

View File

@@ -0,0 +1,32 @@
using System.Text.Json;
using OED.Api.Core.Interfaces.Services;
using OED.Api.Core.Models.Eve;
using StackExchange.Redis;
namespace OED.Api.Infrastructure.Auth;
public class SessionService(IConnectionMultiplexer redis) : ISessionService
{
private readonly IDatabase _db = redis.GetDatabase();
private static readonly TimeSpan SessionTtl = TimeSpan.FromDays(7);
public async Task<string> CreateAsync(EveSession session)
{
var sessionId = Guid.NewGuid().ToString("N");
var json = JsonSerializer.Serialize(session);
await _db.StringSetAsync($"session:{sessionId}", json, SessionTtl);
return sessionId;
}
public async Task<EveSession?> GetAsync(string sessionId)
{
var json = await _db.StringGetAsync($"session:{sessionId}");
if (json.IsNullOrEmpty) return null;
return JsonSerializer.Deserialize<EveSession>((string)json!);
}
public async Task DeleteAsync(string sessionId)
{
await _db.KeyDeleteAsync($"session:{sessionId}");
}
}

View File

@@ -0,0 +1,28 @@
using StackExchange.Redis;
namespace OED.Api.Infrastructure.Auth;
public interface IStateStore
{
Task<string> GenerateAsync();
Task<bool> ValidateAndConsumeAsync(string state);
}
public class StateStore(IConnectionMultiplexer redis) : IStateStore
{
private readonly IDatabase _db = redis.GetDatabase();
private static readonly TimeSpan StateTtl = TimeSpan.FromMinutes(10);
public async Task<string> GenerateAsync()
{
var state = Guid.NewGuid().ToString("N");
await _db.StringSetAsync($"oauth:state:{state}", "1", StateTtl);
return state;
}
public async Task<bool> ValidateAndConsumeAsync(string state)
{
// Delete returns true only if the key existed — atomic check + delete in one operation
return await _db.KeyDeleteAsync($"oauth:state:{state}");
}
}

View File

@@ -0,0 +1,18 @@
using Microsoft.AspNetCore.DataProtection;
namespace OED.Api.Infrastructure.Auth;
public interface ITokenEncryptor
{
string Encrypt(string plaintext);
string Decrypt(string ciphertext);
}
// Uses ASP.NET Data Protection — handles key management, rotation, and storage automatically
public class TokenEncryptor(IDataProtectionProvider provider) : ITokenEncryptor
{
private readonly IDataProtector _protector = provider.CreateProtector("Eve.RefreshToken.v1");
public string Encrypt(string plaintext) => _protector.Protect(plaintext);
public string Decrypt(string ciphertext) => _protector.Unprotect(ciphertext);
}

View File

@@ -0,0 +1,31 @@
using OED.Api.Core.Interfaces.Services;
using StackExchange.Redis;
namespace OED.Api.Infrastructure.Auth;
public class TokenStore(IConnectionMultiplexer redis, ITokenEncryptor encryptor) : ITokenStore
{
private readonly IDatabase _db = redis.GetDatabase();
public async Task SetAccessTokenAsync(long characterId, string token, TimeSpan expiry)
=> await _db.StringSetAsync($"accesstoken:{characterId}", token, expiry);
public async Task<string?> GetAccessTokenAsync(long characterId)
{
var val = await _db.StringGetAsync($"accesstoken:{characterId}");
return val.IsNullOrEmpty ? null : val.ToString();
}
public async Task SetRefreshTokenAsync(long characterId, string token)
{
var encrypted = encryptor.Encrypt(token);
await _db.StringSetAsync($"refreshtoken:{characterId}", encrypted);
}
public async Task<string?> GetRefreshTokenAsync(long characterId)
{
var val = await _db.StringGetAsync($"refreshtoken:{characterId}");
if (val.IsNullOrEmpty) return null;
return encryptor.Decrypt(val.ToString());
}
}

View File

@@ -0,0 +1,36 @@
using System.Net.Http.Headers;
using OED.Api.Core.Interfaces.Services;
namespace OED.Api.Infrastructure.Esi;
public class EsiAuthHandler(
ITokenStore tokenStore,
IEsiTokenRefreshService refreshService,
IHttpContextAccessor httpContextAccessor
) : DelegatingHandler
{
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
var characterId = GetCharacterIdFromSession();
if (characterId is null)
return await base.SendAsync(request, cancellationToken);
var accessToken = await tokenStore.GetAccessTokenAsync(characterId.Value);
if (accessToken is null)
accessToken = await refreshService.RefreshAsync(characterId.Value);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
return await base.SendAsync(request, cancellationToken);
}
private long? GetCharacterIdFromSession()
{
var context = httpContextAccessor.HttpContext;
// CharacterId is set on the HttpContext by session middleware
return context?.Items["CharacterId"] as long?;
}
}

View File

@@ -0,0 +1,61 @@
using OED.Api.Core.Interfaces.Services;
namespace OED.Api.Infrastructure.Esi;
public interface IEsiTokenRefreshService
{
Task<string> RefreshAsync(long characterId);
}
public class EsiTokenRefreshService(
IHttpClientFactory httpClientFactory,
ITokenStore tokenStore,
IConfiguration config
) : IEsiTokenRefreshService
{
public async Task<string> RefreshAsync(long characterId)
{
var refreshToken = await tokenStore.GetRefreshTokenAsync(characterId)
?? throw new InvalidOperationException($"No refresh token found for character {characterId}");
var clientId = config["Eve:ClientId"]!;
var secretKey = config["Eve:SecretKey"]!;
var credentials = Convert.ToBase64String(
System.Text.Encoding.UTF8.GetBytes($"{clientId}:{secretKey}")
);
var client = httpClientFactory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "https://login.eveonline.com/v2/oauth/token");
request.Headers.Add("Authorization", $"Basic {credentials}");
request.Content = new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "refresh_token",
["refresh_token"] = refreshToken
});
var response = await client.SendAsync(request);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<EsiTokenResponse>()
?? throw new InvalidOperationException("Empty token response from EVE SSO");
// Always overwrite — EVE may rotate the refresh token
await tokenStore.SetRefreshTokenAsync(characterId, result.RefreshToken);
await tokenStore.SetAccessTokenAsync(characterId, result.AccessToken, TimeSpan.FromSeconds(result.ExpiresIn - 30));
return result.AccessToken;
}
}
public record EsiTokenResponse(
string access_token,
int expires_in,
string token_type,
string refresh_token
)
{
public string AccessToken => access_token;
public int ExpiresIn => expires_in;
public string RefreshToken => refresh_token;
}

View File

@@ -7,7 +7,26 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.3"/>
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.1" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.23" />
<PackageReference Include="Hangfire.PostgreSql" Version="1.21.1" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.3">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.16.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
<PackageReference Include="Refit" Version="10.0.1" />
<PackageReference Include="Refit.HttpClientFactory" Version="10.0.1" />
<PackageReference Include="Riok.Mapperly" Version="4.3.1" />
<PackageReference Include="Scalar.AspNetCore" Version="2.13.2" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="StackExchange.Redis" Version="2.11.8" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.16.0" />
</ItemGroup>
</Project>

View File

@@ -1,6 +0,0 @@
@OED.Api_HostAddress = http://localhost:5163
GET {{OED.Api_HostAddress}}/weatherforecast/
Accept: application/json
###

View File

@@ -1,21 +1,51 @@
using Microsoft.AspNetCore.DataProtection;
using OED.Api.Core.Interfaces.Services;
using OED.Api.Endpoints;
using OED.Api.Infrastructure.Auth;
using OED.Api.Infrastructure.Esi;
using StackExchange.Redis;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddAuthorization();
// Redis
builder.Services.AddSingleton<IConnectionMultiplexer>(
ConnectionMultiplexer.Connect(builder.Configuration.GetConnectionString("Redis")!)
);
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi();
// Data Protection — handles encryption key management automatically
// Keys are stored in the local file system by default in dev
// In production point this at a shared volume or Azure Key Vault
builder.Services.AddDataProtection()
.SetApplicationName("OED.Api");
// Auth & session
builder.Services.AddSingleton<EveJwtValidator>();
builder.Services.AddScoped<ISessionService, SessionService>();
builder.Services.AddScoped<ITokenStore, TokenStore>();
builder.Services.AddScoped<IStateStore, StateStore>();
builder.Services.AddScoped<ITokenEncryptor, TokenEncryptor>();
builder.Services.AddScoped<IEsiTokenRefreshService, EsiTokenRefreshService>();
builder.Services.AddTransient<EsiAuthHandler>();
builder.Services.AddHttpContextAccessor();
builder.Services.AddHttpClient();
// CORS
builder.Services.AddCors(options =>
{
options.AddPolicy("Frontend", policy =>
policy
.WithOrigins(builder.Configuration["Frontend:Url"]!)
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials()
);
});
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseCors("Frontend");
app.UseMiddleware<SessionMiddleware>();
app.UseHttpsRedirection();
app.MapAuthEndpoints();
app.UseAuthorization();
app.Run();
app.Run();

View File

@@ -1,8 +1,14 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
"Eve": {
"ClientId": "0a2e13f0b0f3426ca8f09af5036978a9",
"SecretKey": "eat_vFwYVybPjD1QkPZM4fXJG2AkrzJO9W8U_10Pqp9",
"CallbackUrl": "http://localhost:5163/auth/callback"
},
"Frontend": {
"Url": "http://localhost:5173"
},
"ConnectionStrings": {
"Redis": "localhost:6379,password=devpassword",
"Postgres": "Host=localhost;Port=5432;Database=oed;Username=oed;Password=devpassword"
}
}