// ==============================================================================
// MORPH-SPEC - Neon Auth Configuration Template
// Configuracao de autenticacao com Neon Auth (Better Auth) para .NET
//
// Neon Auth tokens are signed with EdDSA (Ed25519, JWK kty "OKP"). There is no
// OIDC discovery document (/.well-known/openid-configuration) — only the raw
// /.well-known/jwks.json — and Microsoft.IdentityModel.Tokens cannot verify
// OKP/EdDSA signatures on its own. See:
// .morph/framework/standards/backend/integrations/neon-auth/neon-auth.md
// → "## .NET Backend Integration" for the full reference and rationale.
//
// NuGet (add both):
//
//
// ==============================================================================
using System.Security.Claims;
using System.Text;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
using Org.BouncyCastle.Crypto.Parameters;
using Org.BouncyCastle.Crypto.Signers;
namespace {{Namespace}}.Infrastructure.Auth;
// ==============================================================================
// OPTIONS
// ==============================================================================
public class NeonAuthOptions
{
public const string SectionName = "NeonAuth";
///
/// Neon Auth Base URL (e.g., https://ep-xxx.neonauth.us-east-2.aws.neon.build/neondb/auth).
/// MUST be present in appsettings.Development.json — it's a public value, not a secret.
/// Missing it produces the same symptom as a broken signature validator: every request
/// gets a bare 401 with nothing useful in the logs.
///
public string BaseUrl { get; set; } = string.Empty;
}
// ==============================================================================
// JWKS PROVIDER (cached — Neon Auth has no OIDC discovery to piggyback on)
// ==============================================================================
///
/// Fetches and caches Neon Auth's raw JWKS. Cached for 15 minutes; a forced refresh
/// happens once when an unknown `kid` is seen, to survive key rotation without
/// waiting out the TTL.
///
public sealed class NeonAuthJwksProvider(HttpClient httpClient, IOptions options)
{
private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(15);
private readonly SemaphoreSlim _refreshLock = new(1, 1);
private JsonWebKeySet? _cached;
private DateTimeOffset _cachedAt;
public async Task GetKeyAsync(string kid, CancellationToken cancellationToken)
{
var keySet = await GetKeySetAsync(forceRefresh: false, cancellationToken);
var key = keySet.Keys.FirstOrDefault(k => k.Kid == kid);
if (key is not null) return key;
// Unknown kid — could be key rotation. Force a single refresh before giving up.
keySet = await GetKeySetAsync(forceRefresh: true, cancellationToken);
return keySet.Keys.FirstOrDefault(k => k.Kid == kid);
}
private async Task GetKeySetAsync(bool forceRefresh, CancellationToken cancellationToken)
{
if (!forceRefresh && _cached is not null && DateTimeOffset.UtcNow - _cachedAt < CacheTtl)
return _cached;
await _refreshLock.WaitAsync(cancellationToken);
try
{
if (!forceRefresh && _cached is not null && DateTimeOffset.UtcNow - _cachedAt < CacheTtl)
return _cached;
var jwksUri = $"{options.Value.BaseUrl.TrimEnd('/')}/.well-known/jwks.json";
var json = await httpClient.GetStringAsync(jwksUri, cancellationToken);
_cached = new JsonWebKeySet(json);
_cachedAt = DateTimeOffset.UtcNow;
return _cached;
}
finally
{
_refreshLock.Release();
}
}
}
// ==============================================================================
// SERVICE EXTENSIONS
// ==============================================================================
public static class NeonAuthServiceExtensions
{
///
/// Adds Neon Auth JWT Bearer authentication to the project. Validates EdDSA-signed
/// JWTs issued by Neon Auth via a manually-fetched, cached JWKS — see
/// NeonAuthJwksProvider above and the standard's "## .NET Backend Integration" section
/// for why options.Authority / the default IssuerSigningKeyResolver do not work here.
///
public static IServiceCollection AddNeonAuthentication(
this IServiceCollection services,
IConfiguration configuration)
{
services.Configure(configuration.GetSection(NeonAuthOptions.SectionName));
services.AddHttpClient();
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
// Do NOT set options.Authority — Neon Auth serves no
// /.well-known/openid-configuration, so Authority-driven metadata refresh
// has nothing valid to fetch. ValidIssuer is set explicitly instead.
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = configuration["NeonAuth:BaseUrl"],
ValidateAudience = false,
ValidateLifetime = true,
NameClaimType = "sub",
// Trust in the signing key is established entirely inside
// SignatureValidator below (fetched from our own cached JWKS) —
// this flag doesn't gate that path.
ValidateIssuerSigningKey = false,
};
});
// A second Configure pass with DI available — AddJwtBearer's own setup has
// already run, so this only *adds* the SignatureValidator to the
// TokenValidationParameters instance built above.
services.AddOptions(JwtBearerDefaults.AuthenticationScheme)
.Configure((options, jwks) =>
{
options.TokenValidationParameters.SignatureValidator = (token, _) =>
{
var jwt = new JsonWebToken(token);
// Pin the algorithm — refuse anything that isn't EdDSA to block
// alg-substitution attacks.
if (!string.Equals(jwt.Alg, "EdDSA", StringComparison.Ordinal))
throw new SecurityTokenInvalidSignatureException(
$"Unsupported alg '{jwt.Alg}' — Neon Auth tokens must be EdDSA.");
var kid = jwt.Kid;
if (string.IsNullOrEmpty(kid))
throw new SecurityTokenInvalidSignatureException("Token is missing 'kid'.");
// Blocking call: JWKS is cached in-memory, so this is I/O-free on the hot
// path. SignatureValidator has no async overload — only a cold-start /
// key-rotation miss ever awaits real I/O here.
var jwk = jwks.GetKeyAsync(kid, CancellationToken.None).GetAwaiter().GetResult()
?? throw new SecurityTokenInvalidSignatureException(
$"No JWKS key found for kid '{kid}'.");
if (jwk.Kty != "OKP" || jwk.Crv != "Ed25519")
throw new SecurityTokenInvalidSignatureException(
"JWKS key is not an Ed25519 (OKP) key.");
var parts = token.Split('.');
if (parts.Length != 3)
throw new SecurityTokenInvalidSignatureException(
"Malformed JWT — expected 3 segments.");
var signingInput = Encoding.ASCII.GetBytes($"{parts[0]}.{parts[1]}");
var signature = Base64UrlEncoder.DecodeBytes(parts[2]);
var publicKey = new Ed25519PublicKeyParameters(Base64UrlEncoder.DecodeBytes(jwk.X), 0);
var verifier = new Ed25519Signer();
verifier.Init(forSigning: false, publicKey);
verifier.BlockUpdate(signingInput, 0, signingInput.Length);
if (!verifier.VerifySignature(signature))
throw new SecurityTokenInvalidSignatureException(
"Ed25519 signature verification failed.");
// MUST return Microsoft.IdentityModel.JsonWebTokens.JsonWebToken — ASP.NET
// Core 8+ uses JsonWebTokenHandler internally. Returning a
// System.IdentityModel.Tokens.Jwt.JwtSecurityToken here fails with
// IDX10506 ("SignatureValidator returned a token of an unexpected type").
return jwt;
};
});
services.AddAuthorization();
return services;
}
}
// ==============================================================================
// USER CONTEXT SERVICE
// ==============================================================================
public interface ICurrentUserService
{
string? UserId { get; }
string? Email { get; }
bool IsAuthenticated { get; }
}
public class CurrentUserService(IHttpContextAccessor httpContextAccessor) : ICurrentUserService
{
public string? UserId =>
httpContextAccessor.HttpContext?.User.FindFirstValue("sub");
public string? Email =>
httpContextAccessor.HttpContext?.User.FindFirstValue("email");
public bool IsAuthenticated =>
httpContextAccessor.HttpContext?.User.Identity?.IsAuthenticated ?? false;
}
// ==============================================================================
// MIDDLEWARE PIPELINE
// ==============================================================================
public static class NeonAuthMiddlewareExtensions
{
public static IApplicationBuilder UseNeonAuthentication(this IApplicationBuilder app)
{
app.UseAuthentication();
app.UseAuthorization();
return app;
}
}
// ==============================================================================
// APPSETTINGS.DEVELOPMENT.JSON — mandatory, public value, safe to commit
// ==============================================================================
/*
{
"NeonAuth": {
"BaseUrl": "https://ep-xxx.neonauth.us-east-2.aws.neon.build/neondb/auth"
}
}
*/
// ==============================================================================
// PROGRAM.CS EXAMPLE
// ==============================================================================
/*
var builder = WebApplication.CreateBuilder(args);
// Add Neon Auth authentication (registers the JWKS provider + EdDSA signature validator)
builder.Services.AddNeonAuthentication(builder.Configuration);
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped();
var app = builder.Build();
app.UseNeonAuthentication();
app.MapControllers().RequireAuthorization();
app.Run();
*/