// ============================================================================== // 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(); */