@polymorphism-tech/morph-spec 7.9.1 → 7.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,28 @@
1
1
  // ==============================================================================
2
2
  // MORPH-SPEC - Neon Auth Configuration Template
3
3
  // Configuracao de autenticacao com Neon Auth (Better Auth) para .NET
4
+ //
5
+ // Neon Auth tokens are signed with EdDSA (Ed25519, JWK kty "OKP"). There is no
6
+ // OIDC discovery document (/.well-known/openid-configuration) — only the raw
7
+ // /.well-known/jwks.json — and Microsoft.IdentityModel.Tokens cannot verify
8
+ // OKP/EdDSA signatures on its own. See:
9
+ // .morph/framework/standards/backend/integrations/neon-auth/neon-auth.md
10
+ // → "## .NET Backend Integration" for the full reference and rationale.
11
+ //
12
+ // NuGet (add both):
13
+ // <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.3" />
14
+ // <PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" /> <!-- pin >= 2.3.1, CVE-2024-30172 -->
4
15
  // ==============================================================================
5
16
 
17
+ using System.Security.Claims;
18
+ using System.Text;
6
19
  using Microsoft.AspNetCore.Authentication;
7
20
  using Microsoft.AspNetCore.Authentication.JwtBearer;
21
+ using Microsoft.Extensions.Options;
22
+ using Microsoft.IdentityModel.JsonWebTokens;
8
23
  using Microsoft.IdentityModel.Tokens;
9
- using System.Security.Claims;
24
+ using Org.BouncyCastle.Crypto.Parameters;
25
+ using Org.BouncyCastle.Crypto.Signers;
10
26
 
11
27
  namespace {{Namespace}}.Infrastructure.Auth;
12
28
 
@@ -19,11 +35,65 @@ public class NeonAuthOptions
19
35
  public const string SectionName = "NeonAuth";
20
36
 
21
37
  /// <summary>
22
- /// Neon Auth Base URL (e.g., https://ep-xxx.neonauth.us-east-2.aws.neon.build/neondb/auth)
38
+ /// Neon Auth Base URL (e.g., https://ep-xxx.neonauth.us-east-2.aws.neon.build/neondb/auth).
39
+ /// MUST be present in appsettings.Development.json — it's a public value, not a secret.
40
+ /// Missing it produces the same symptom as a broken signature validator: every request
41
+ /// gets a bare 401 with nothing useful in the logs.
23
42
  /// </summary>
24
43
  public string BaseUrl { get; set; } = string.Empty;
25
44
  }
26
45
 
46
+ // ==============================================================================
47
+ // JWKS PROVIDER (cached — Neon Auth has no OIDC discovery to piggyback on)
48
+ // ==============================================================================
49
+
50
+ /// <summary>
51
+ /// Fetches and caches Neon Auth's raw JWKS. Cached for 15 minutes; a forced refresh
52
+ /// happens once when an unknown `kid` is seen, to survive key rotation without
53
+ /// waiting out the TTL.
54
+ /// </summary>
55
+ public sealed class NeonAuthJwksProvider(HttpClient httpClient, IOptions<NeonAuthOptions> options)
56
+ {
57
+ private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(15);
58
+ private readonly SemaphoreSlim _refreshLock = new(1, 1);
59
+ private JsonWebKeySet? _cached;
60
+ private DateTimeOffset _cachedAt;
61
+
62
+ public async Task<JsonWebKey?> GetKeyAsync(string kid, CancellationToken cancellationToken)
63
+ {
64
+ var keySet = await GetKeySetAsync(forceRefresh: false, cancellationToken);
65
+ var key = keySet.Keys.FirstOrDefault(k => k.Kid == kid);
66
+ if (key is not null) return key;
67
+
68
+ // Unknown kid — could be key rotation. Force a single refresh before giving up.
69
+ keySet = await GetKeySetAsync(forceRefresh: true, cancellationToken);
70
+ return keySet.Keys.FirstOrDefault(k => k.Kid == kid);
71
+ }
72
+
73
+ private async Task<JsonWebKeySet> GetKeySetAsync(bool forceRefresh, CancellationToken cancellationToken)
74
+ {
75
+ if (!forceRefresh && _cached is not null && DateTimeOffset.UtcNow - _cachedAt < CacheTtl)
76
+ return _cached;
77
+
78
+ await _refreshLock.WaitAsync(cancellationToken);
79
+ try
80
+ {
81
+ if (!forceRefresh && _cached is not null && DateTimeOffset.UtcNow - _cachedAt < CacheTtl)
82
+ return _cached;
83
+
84
+ var jwksUri = $"{options.Value.BaseUrl.TrimEnd('/')}/.well-known/jwks.json";
85
+ var json = await httpClient.GetStringAsync(jwksUri, cancellationToken);
86
+ _cached = new JsonWebKeySet(json);
87
+ _cachedAt = DateTimeOffset.UtcNow;
88
+ return _cached;
89
+ }
90
+ finally
91
+ {
92
+ _refreshLock.Release();
93
+ }
94
+ }
95
+ }
96
+
27
97
  // ==============================================================================
28
98
  // SERVICE EXTENSIONS
29
99
  // ==============================================================================
@@ -31,28 +101,91 @@ public class NeonAuthOptions
31
101
  public static class NeonAuthServiceExtensions
32
102
  {
33
103
  /// <summary>
34
- /// Adds Neon Auth JWT Bearer authentication to the project.
35
- /// Validates JWTs issued by Neon Auth service.
104
+ /// Adds Neon Auth JWT Bearer authentication to the project. Validates EdDSA-signed
105
+ /// JWTs issued by Neon Auth via a manually-fetched, cached JWKS — see
106
+ /// NeonAuthJwksProvider above and the standard's "## .NET Backend Integration" section
107
+ /// for why options.Authority / the default IssuerSigningKeyResolver do not work here.
36
108
  /// </summary>
37
109
  public static IServiceCollection AddNeonAuthentication(
38
110
  this IServiceCollection services,
39
111
  IConfiguration configuration)
40
112
  {
41
113
  services.Configure<NeonAuthOptions>(configuration.GetSection(NeonAuthOptions.SectionName));
42
-
43
- var neonAuthOptions = configuration.GetSection(NeonAuthOptions.SectionName).Get<NeonAuthOptions>()!;
114
+ services.AddHttpClient<NeonAuthJwksProvider>();
44
115
 
45
116
  services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
46
117
  .AddJwtBearer(options =>
47
118
  {
48
- options.Authority = neonAuthOptions.BaseUrl;
119
+ // Do NOT set options.Authority — Neon Auth serves no
120
+ // /.well-known/openid-configuration, so Authority-driven metadata refresh
121
+ // has nothing valid to fetch. ValidIssuer is set explicitly instead.
49
122
  options.TokenValidationParameters = new TokenValidationParameters
50
123
  {
51
124
  ValidateIssuer = true,
125
+ ValidIssuer = configuration["NeonAuth:BaseUrl"],
52
126
  ValidateAudience = false,
53
127
  ValidateLifetime = true,
54
- ValidateIssuerSigningKey = true,
55
- NameClaimType = "sub"
128
+ NameClaimType = "sub",
129
+ // Trust in the signing key is established entirely inside
130
+ // SignatureValidator below (fetched from our own cached JWKS) —
131
+ // this flag doesn't gate that path.
132
+ ValidateIssuerSigningKey = false,
133
+ };
134
+ });
135
+
136
+ // A second Configure<TDep> pass with DI available — AddJwtBearer's own setup has
137
+ // already run, so this only *adds* the SignatureValidator to the
138
+ // TokenValidationParameters instance built above.
139
+ services.AddOptions<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme)
140
+ .Configure<NeonAuthJwksProvider>((options, jwks) =>
141
+ {
142
+ options.TokenValidationParameters.SignatureValidator = (token, _) =>
143
+ {
144
+ var jwt = new JsonWebToken(token);
145
+
146
+ // Pin the algorithm — refuse anything that isn't EdDSA to block
147
+ // alg-substitution attacks.
148
+ if (!string.Equals(jwt.Alg, "EdDSA", StringComparison.Ordinal))
149
+ throw new SecurityTokenInvalidSignatureException(
150
+ $"Unsupported alg '{jwt.Alg}' — Neon Auth tokens must be EdDSA.");
151
+
152
+ var kid = jwt.Kid;
153
+ if (string.IsNullOrEmpty(kid))
154
+ throw new SecurityTokenInvalidSignatureException("Token is missing 'kid'.");
155
+
156
+ // Blocking call: JWKS is cached in-memory, so this is I/O-free on the hot
157
+ // path. SignatureValidator has no async overload — only a cold-start /
158
+ // key-rotation miss ever awaits real I/O here.
159
+ var jwk = jwks.GetKeyAsync(kid, CancellationToken.None).GetAwaiter().GetResult()
160
+ ?? throw new SecurityTokenInvalidSignatureException(
161
+ $"No JWKS key found for kid '{kid}'.");
162
+
163
+ if (jwk.Kty != "OKP" || jwk.Crv != "Ed25519")
164
+ throw new SecurityTokenInvalidSignatureException(
165
+ "JWKS key is not an Ed25519 (OKP) key.");
166
+
167
+ var parts = token.Split('.');
168
+ if (parts.Length != 3)
169
+ throw new SecurityTokenInvalidSignatureException(
170
+ "Malformed JWT — expected 3 segments.");
171
+
172
+ var signingInput = Encoding.ASCII.GetBytes($"{parts[0]}.{parts[1]}");
173
+ var signature = Base64UrlEncoder.DecodeBytes(parts[2]);
174
+ var publicKey = new Ed25519PublicKeyParameters(Base64UrlEncoder.DecodeBytes(jwk.X), 0);
175
+
176
+ var verifier = new Ed25519Signer();
177
+ verifier.Init(forSigning: false, publicKey);
178
+ verifier.BlockUpdate(signingInput, 0, signingInput.Length);
179
+
180
+ if (!verifier.VerifySignature(signature))
181
+ throw new SecurityTokenInvalidSignatureException(
182
+ "Ed25519 signature verification failed.");
183
+
184
+ // MUST return Microsoft.IdentityModel.JsonWebTokens.JsonWebToken — ASP.NET
185
+ // Core 8+ uses JsonWebTokenHandler internally. Returning a
186
+ // System.IdentityModel.Tokens.Jwt.JwtSecurityToken here fails with
187
+ // IDX10506 ("SignatureValidator returned a token of an unexpected type").
188
+ return jwt;
56
189
  };
57
190
  });
58
191
 
@@ -100,7 +233,7 @@ public static class NeonAuthMiddlewareExtensions
100
233
  }
101
234
 
102
235
  // ==============================================================================
103
- // APPSETTINGS EXAMPLE
236
+ // APPSETTINGS.DEVELOPMENT.JSON — mandatory, public value, safe to commit
104
237
  // ==============================================================================
105
238
 
106
239
  /*
@@ -118,7 +251,7 @@ public static class NeonAuthMiddlewareExtensions
118
251
  /*
119
252
  var builder = WebApplication.CreateBuilder(args);
120
253
 
121
- // Add Neon Auth authentication
254
+ // Add Neon Auth authentication (registers the JWKS provider + EdDSA signature validator)
122
255
  builder.Services.AddNeonAuthentication(builder.Configuration);
123
256
  builder.Services.AddHttpContextAccessor();
124
257
  builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polymorphism-tech/morph-spec",
3
- "version": "7.9.1",
3
+ "version": "7.9.3",
4
4
  "description": "MORPH-SPEC: NLH (Natural Language Harness) for spec-driven development with Claude Code",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -974,7 +974,7 @@ export async function doctorCommand(options = {}) {
974
974
 
975
975
  if (cliCheck.isOutdated || projectCheck.isOutdated) {
976
976
  logger.info('To update:');
977
- logger.dim(' 1. Update the CLI: npm install -g @polymorphism-tech/morph-spec@latest');
977
+ logger.dim(' 1. Update the CLI: npm install -g @polymorphism-tech/morph-spec@latest --prefer-online');
978
978
  logger.dim(' 2. Update the project: morph-spec update');
979
979
  logger.blank();
980
980
  }
@@ -121,13 +121,13 @@ export function getUpdateInstructions(method) {
121
121
  switch (method) {
122
122
  case 'global':
123
123
  return [
124
- 'npm install -g @polymorphism-tech/morph-spec@latest',
124
+ 'npm install -g @polymorphism-tech/morph-spec@latest --prefer-online',
125
125
  'morph-spec update'
126
126
  ];
127
127
  case 'npx':
128
128
  default:
129
129
  return [
130
- 'npm install -g @polymorphism-tech/morph-spec@latest',
130
+ 'npm install -g @polymorphism-tech/morph-spec@latest --prefer-online',
131
131
  'morph-spec update'
132
132
  ];
133
133
  }