@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.
@@ -2,10 +2,15 @@
2
2
 
3
3
  > **Scope:** universal
4
4
  > **Layer:** 2 (on keyword)
5
- > **Keywords:** neon auth, better auth, authentication, nextjs, user management, session, JWT
5
+ > **Keywords:** neon auth, better auth, authentication, nextjs, user management, session, JWT, trusted origins, EdDSA, Ed25519, BouncyCastle, signature validator, jwks, middleware runtime, api prefix
6
6
  > **Load When:** neon auth or authentication keywords detected
7
7
 
8
- **Verified against:** Neon Auth — @neondatabase/auth + @neondatabase/auth-ui (Better Auth). Last-verified: 2026-05-20.
8
+ **Verified against:** Neon Auth — @neondatabase/auth + @neondatabase/auth-ui (Better Auth). Last-verified: 2026-07-13.
9
+
10
+ > This standard is the reference implementation, not a troubleshooting log. Every section below
11
+ > exists because a real end-to-end run (MORPH_GHLBrain, feature `catalogo-produtos-crud`, task T16,
12
+ > 2026-07-13) hit the failure mode it documents. See [Known Failure Modes](#known-failure-modes-postmortem)
13
+ > for the map from bug → section.
9
14
 
10
15
  ---
11
16
 
@@ -33,6 +38,9 @@ Neon Auth provides:
33
38
  2. **Branch Isolation**: Each Neon branch gets isolated users, sessions, and auth config
34
39
  3. **Server-First**: Use server-side auth checks with `auth.getSession()`
35
40
  4. **JWT for RLS**: Tokens contain `sub` (user ID), `email`, `role: "authenticated"` for database policies
41
+ 5. **No OIDC discovery**: Neon Auth does **not** expose `/.well-known/openid-configuration` — only the
42
+ raw `/.well-known/jwks.json`. Never assume `options.Authority` alone gives you working discovery on
43
+ the .NET side (see [.NET Backend Integration](#net-backend-integration)).
36
44
 
37
45
  ---
38
46
 
@@ -65,6 +73,27 @@ Generate cookie secret:
65
73
  openssl rand -base64 32
66
74
  ```
67
75
 
76
+ ### Onboarding Checklist — Do This Before Testing Login (mandatory)
77
+
78
+ Run once per environment (local dev, every preview URL, every new dev machine) — **before** the first
79
+ real sign-in attempt, not after debugging a mysterious "session never sticks" bug:
80
+
81
+ 1. **Register every dev/preview origin in `trusted_origins`.** Neon Auth's middleware silently refuses
82
+ to recognize a session on a full-page navigation from an origin it doesn't know about — there is no
83
+ error, the user just bounces back to sign-in as if the session never existed. `localhost:3000` is not
84
+ automatically enough; a second dev port (`localhost:3001`), a new preview deploy URL, or a tunnel URL
85
+ each need to be added explicitly. Use the `configure_neon_auth` tool (Neon MCP) to list current
86
+ `trusted_origins` and add the one you're about to test against:
87
+ ```
88
+ mcp: Neon.configure_neon_auth → confirm/add the exact origin (scheme + host + port) you will open in the browser
89
+ ```
90
+ Do this **before** attempting a real login — not as the first debugging step after login silently fails.
91
+ 2. Confirm `NEON_AUTH_COOKIE_SECRET` is 32+ characters (`openssl rand -base64 32`).
92
+ 3. Confirm `NEON_AUTH_BASE_URL` / `NEXT_PUBLIC_AUTH_URL` point at the same Neon Auth endpoint you
93
+ configured `trusted_origins` against (branch-specific — see [Branch Isolation](#branch-isolation)).
94
+ 4. On the .NET side, confirm `NeonAuth:BaseUrl` exists in `appsettings.Development.json` — see
95
+ [appsettings.Development.json (mandatory)](#appsettingsdevelopmentjson-mandatory-public-value).
96
+
68
97
  ### SDK Initialization
69
98
 
70
99
  ```typescript
@@ -195,6 +224,12 @@ export function UserProfile() {
195
224
  // middleware.ts
196
225
  import { auth } from '@/lib/auth/server';
197
226
 
227
+ // MANDATORY for any middleware.ts that touches Neon Auth. Next.js middleware runs on the Edge
228
+ // runtime by default, and the network call the Neon Auth SDK makes to mint the session-data cookie
229
+ // times out inside the Edge sandbox on `next dev` — the request silently bounces back to sign-in
230
+ // even with a fully valid session, with no error surfaced. Force the Node.js runtime instead.
231
+ export const runtime = 'nodejs';
232
+
198
233
  export default auth.middleware({
199
234
  loginUrl: '/auth/sign-in',
200
235
  });
@@ -262,29 +297,274 @@ export async function GET() {
262
297
 
263
298
  ---
264
299
 
300
+ ## API Prefix Convention — Server-Direct vs Client-Proxy
301
+
302
+ A Next.js app talking to a Neon-Auth-protected .NET backend fetches from **two different places**
303
+ depending on where the code runs:
304
+
305
+ - **Server Components / Route Handlers** call the .NET backend directly (server-to-server, no browser
306
+ involved) — the backend's own route prefix applies (e.g. the backend exposes `catalog/items`, no
307
+ `/api` prefix at all if that's how the VSA endpoints are mapped).
308
+ - **Client Components** cannot call the .NET backend's origin directly without CORS/cookie complications,
309
+ so they call a **same-origin Next.js proxy route** (e.g. `/api/catalog/items`) which forwards the
310
+ request (and the session cookie) to the .NET backend.
311
+
312
+ These are **two distinct prefixes with two distinct purposes**. Collapsing them into a single shared
313
+ constant (`API_PREFIX = '/api'` used for both) silently breaks one of the two call sites the moment the
314
+ backend's real prefix and the proxy's public prefix don't happen to match character-for-character — and
315
+ because this is a runtime routing mismatch (404, or the proxy calling itself), it **never shows up at
316
+ build time**, only when a human clicks the page.
317
+
318
+ ```typescript
319
+ // lib/api/prefixes.ts
320
+ // BACKEND_API_PREFIX: used ONLY by Server Components / Route Handlers calling the .NET backend directly.
321
+ export const BACKEND_API_PREFIX = process.env.API_URL; // e.g. https://api.example.com — backend's own routes have NO /api prefix
322
+
323
+ // PROXY_API_PREFIX: used ONLY by Client Components calling the same-origin Next.js proxy.
324
+ export const PROXY_API_PREFIX = '/api/proxy'; // matches app/api/proxy/[...path]/route.ts
325
+ ```
326
+
327
+ ```typescript
328
+ // app/(dashboard)/catalog/page.tsx — Server Component, direct backend call
329
+ import { BACKEND_API_PREFIX } from '@/lib/api/prefixes';
330
+
331
+ async function getItems() {
332
+ // cross-reference: PROXY_API_PREFIX below MUST resolve to the same backend route
333
+ const res = await fetch(`${BACKEND_API_PREFIX}/catalog/items`, { next: { revalidate: 60 } });
334
+ return res.json();
335
+ }
336
+ ```
337
+
338
+ ```typescript
339
+ // features/catalog/hooks/use-items.ts — Client Component, via same-origin proxy
340
+ import { PROXY_API_PREFIX } from '@/lib/api/prefixes';
341
+
342
+ export function useItems() {
343
+ return useQuery({
344
+ queryKey: ['catalog', 'items'],
345
+ // cross-reference: BACKEND_API_PREFIX above MUST resolve to the same backend route
346
+ queryFn: async () => (await fetch(`${PROXY_API_PREFIX}/catalog/items`)).json(),
347
+ });
348
+ }
349
+ ```
350
+
351
+ **Rule:** never reuse `BACKEND_API_PREFIX` for a proxy call, and never reuse `PROXY_API_PREFIX` for a
352
+ direct server call. If a review needs to check this mechanically: grep for both constants and confirm
353
+ neither name appears on the "wrong" side (Server Component files should only import
354
+ `BACKEND_API_PREFIX`; Client Component / hook files should only import `PROXY_API_PREFIX`).
355
+
356
+ ---
357
+
265
358
  ## .NET Backend Integration
266
359
 
267
- Neon Auth issues standard JWTs. The .NET API validates these tokens directly — no external auth SDK needed.
360
+ Neon Auth issues standard JWTs, **signed with EdDSA (Ed25519, JWK `kty: "OKP"`)**. The .NET API must
361
+ validate these tokens itself — there is no external auth SDK and, critically, **no OIDC discovery
362
+ document to lean on**.
363
+
364
+ > **`options.Authority` alone does not work here.** Neon Auth does not serve
365
+ > `/.well-known/openid-configuration` — only the raw `/.well-known/jwks.json`. Setting `Authority` and
366
+ > trusting the framework's normal metadata-refresh machinery either does nothing useful or wastes retries
367
+ > against a URL that never returns valid OIDC metadata. Fetch and cache the JWKS yourself (below).
368
+
369
+ > **`Microsoft.IdentityModel.Tokens` cannot verify these signatures out of the box.** It has no support
370
+ > for OKP (EdDSA/Ed25519) keys — `JsonWebKeySet.GetSigningKeys()` silently drops any OKP key, so
371
+ > `IssuerSigningKeyResolver` never gets a usable key, and every legitimate token is rejected with a bare
372
+ > `401` and no useful error message. .NET has no built-in (BCL) Ed25519 primitive either. The fix below
373
+ > uses `BouncyCastle.Cryptography` (pure C#, no native `libsodium` dependency — safe on Linux containers)
374
+ > and a manual `TokenValidationParameters.SignatureValidator`.
375
+
376
+ ### NuGet Packages
377
+
378
+ ```xml
379
+ <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.3" />
380
+ <!-- Pure C# Ed25519 — .NET's BCL has no EdDSA primitive. Pin >= 2.3.1: CVE-2024-30172 (infinite loop
381
+ on a crafted signature) was fixed in 2.3.1; use the current stable release. -->
382
+ <PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
383
+ ```
384
+
385
+ ### DI Registration Checklist (do this before marking any auth task done)
386
+
387
+ Minimal API endpoints infer a lambda parameter's binding source from its type: known simple types come
388
+ from the route/query, `ClaimsPrincipal`/`CancellationToken` are special-cased, and any other complex
389
+ type is bound to the **request body** *unless* it's registered in the DI container, in which case it's
390
+ resolved as a service instead. If you inject a custom service (a tenant resolver, a current-user
391
+ accessor, anything beyond the built-ins) into an endpoint lambda **without registering it**, ASP.NET
392
+ Core silently treats it as a second `[FromBody]` parameter — and the app crashes at startup with
393
+ *"Action ... has more than one parameter that was specified or inferred as bound from request body"*,
394
+ taking down every endpoint in that assembly, not just the one you forgot.
395
+
396
+ Before marking any task that adds or touches an endpoint as done:
397
+ 1. List every parameter type in the endpoint lambda that isn't `TRequest`, `CancellationToken`,
398
+ `ClaimsPrincipal`, or a route/query primitive.
399
+ 2. For each one, confirm a matching `AddScoped<T>()` / `AddSingleton<T>()` / `AddTransient<T>()` exists
400
+ in `Program.cs` (or via assembly-scanning registration).
401
+ 3. If it's missing, the fix is the registration — not a `[FromServices]` attribute band-aid.
268
402
 
269
403
  ### JWT Bearer Configuration
270
404
 
271
405
  ```csharp
272
- // Program.cs
273
- builder.Services.AddAuthentication("NeonAuth")
274
- .AddJwtBearer("NeonAuth", options =>
406
+ // Infrastructure/Auth/NeonAuthOptions.cs
407
+ namespace {ProjectName}.Infrastructure.Auth;
408
+
409
+ public sealed class NeonAuthOptions
410
+ {
411
+ public const string SectionName = "NeonAuth";
412
+
413
+ /// <summary>Neon Auth Base URL, e.g. https://ep-xxx.neonauth.us-east-2.aws.neon.build/neondb/auth</summary>
414
+ public string BaseUrl { get; set; } = string.Empty;
415
+ }
416
+ ```
417
+
418
+ ```csharp
419
+ // Infrastructure/Auth/NeonAuthJwksProvider.cs
420
+ namespace {ProjectName}.Infrastructure.Auth;
421
+
422
+ using Microsoft.Extensions.Options;
423
+ using Microsoft.IdentityModel.Tokens;
424
+
425
+ /// <summary>
426
+ /// Fetches and caches Neon Auth's raw JWKS (/.well-known/jwks.json — there is no OIDC discovery
427
+ /// document). Cached for 15 minutes; a forced refresh happens once when an unknown `kid` is seen,
428
+ /// to survive key rotation without waiting out the TTL.
429
+ /// </summary>
430
+ public sealed class NeonAuthJwksProvider(HttpClient httpClient, IOptions<NeonAuthOptions> options)
431
+ {
432
+ private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(15);
433
+ private readonly SemaphoreSlim _refreshLock = new(1, 1);
434
+ private JsonWebKeySet? _cached;
435
+ private DateTimeOffset _cachedAt;
436
+
437
+ public async Task<JsonWebKey?> GetKeyAsync(string kid, CancellationToken cancellationToken)
275
438
  {
276
- options.Authority = builder.Configuration["NeonAuth:BaseUrl"];
277
- options.TokenValidationParameters = new()
439
+ var keySet = await GetKeySetAsync(forceRefresh: false, cancellationToken);
440
+ var key = keySet.Keys.FirstOrDefault(k => k.Kid == kid);
441
+ if (key is not null) return key;
442
+
443
+ // Unknown kid — could be key rotation. Force a single refresh before giving up.
444
+ keySet = await GetKeySetAsync(forceRefresh: true, cancellationToken);
445
+ return keySet.Keys.FirstOrDefault(k => k.Kid == kid);
446
+ }
447
+
448
+ private async Task<JsonWebKeySet> GetKeySetAsync(bool forceRefresh, CancellationToken cancellationToken)
449
+ {
450
+ if (!forceRefresh && _cached is not null && DateTimeOffset.UtcNow - _cachedAt < CacheTtl)
451
+ return _cached;
452
+
453
+ await _refreshLock.WaitAsync(cancellationToken);
454
+ try
278
455
  {
279
- ValidateIssuer = true,
280
- ValidateAudience = false,
281
- ValidateLifetime = true,
282
- ValidateIssuerSigningKey = true,
283
- NameClaimType = "sub"
284
- };
285
- });
286
-
287
- builder.Services.AddAuthorization();
456
+ if (!forceRefresh && _cached is not null && DateTimeOffset.UtcNow - _cachedAt < CacheTtl)
457
+ return _cached;
458
+
459
+ var jwksUri = $"{options.Value.BaseUrl.TrimEnd('/')}/.well-known/jwks.json";
460
+ var json = await httpClient.GetStringAsync(jwksUri, cancellationToken);
461
+ _cached = new JsonWebKeySet(json);
462
+ _cachedAt = DateTimeOffset.UtcNow;
463
+ return _cached;
464
+ }
465
+ finally
466
+ {
467
+ _refreshLock.Release();
468
+ }
469
+ }
470
+ }
471
+ ```
472
+
473
+ ```csharp
474
+ // Infrastructure/Auth/NeonAuthServiceExtensions.cs
475
+ namespace {ProjectName}.Infrastructure.Auth;
476
+
477
+ using System.Text;
478
+ using Microsoft.AspNetCore.Authentication.JwtBearer;
479
+ using Microsoft.Extensions.Options;
480
+ using Microsoft.IdentityModel.JsonWebTokens;
481
+ using Microsoft.IdentityModel.Tokens;
482
+ using Org.BouncyCastle.Crypto.Parameters;
483
+ using Org.BouncyCastle.Crypto.Signers;
484
+
485
+ public static class NeonAuthServiceExtensions
486
+ {
487
+ public static IServiceCollection AddNeonAuthentication(
488
+ this IServiceCollection services,
489
+ IConfiguration configuration)
490
+ {
491
+ services.Configure<NeonAuthOptions>(configuration.GetSection(NeonAuthOptions.SectionName));
492
+ services.AddHttpClient<NeonAuthJwksProvider>();
493
+
494
+ services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
495
+ .AddJwtBearer(options =>
496
+ {
497
+ // Do NOT set options.Authority — Neon Auth serves no /.well-known/openid-configuration,
498
+ // so Authority-driven metadata refresh has nothing valid to fetch. ValidIssuer is set
499
+ // explicitly below instead.
500
+ options.TokenValidationParameters = new TokenValidationParameters
501
+ {
502
+ ValidateIssuer = true,
503
+ ValidIssuer = configuration["NeonAuth:BaseUrl"],
504
+ ValidateAudience = false,
505
+ ValidateLifetime = true,
506
+ NameClaimType = "sub",
507
+ // Trust in the signing key is established entirely inside SignatureValidator
508
+ // below (fetched from our own cached JWKS) — this flag doesn't gate that path.
509
+ ValidateIssuerSigningKey = false,
510
+ };
511
+ });
512
+
513
+ // A second Configure<TDep> pass with DI available — AddJwtBearer's own setup has already run,
514
+ // so this only *adds* the SignatureValidator to the TokenValidationParameters built above.
515
+ services.AddOptions<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme)
516
+ .Configure<NeonAuthJwksProvider>((options, jwks) =>
517
+ {
518
+ options.TokenValidationParameters.SignatureValidator = (token, _) =>
519
+ {
520
+ var jwt = new JsonWebToken(token);
521
+
522
+ // Pin the algorithm — refuse anything that isn't EdDSA to block alg-substitution attacks.
523
+ if (!string.Equals(jwt.Alg, "EdDSA", StringComparison.Ordinal))
524
+ throw new SecurityTokenInvalidSignatureException(
525
+ $"Unsupported alg '{jwt.Alg}' — Neon Auth tokens must be EdDSA.");
526
+
527
+ var kid = jwt.Kid;
528
+ if (string.IsNullOrEmpty(kid))
529
+ throw new SecurityTokenInvalidSignatureException("Token is missing 'kid'.");
530
+
531
+ // Blocking call: JWKS is cached in-memory (see NeonAuthJwksProvider), so this is
532
+ // I/O-free on the hot path. SignatureValidator has no async overload in this
533
+ // library version — only a cold-start / key-rotation miss ever awaits real I/O here.
534
+ var jwk = jwks.GetKeyAsync(kid, CancellationToken.None).GetAwaiter().GetResult()
535
+ ?? throw new SecurityTokenInvalidSignatureException($"No JWKS key found for kid '{kid}'.");
536
+
537
+ if (jwk.Kty != "OKP" || jwk.Crv != "Ed25519")
538
+ throw new SecurityTokenInvalidSignatureException("JWKS key is not an Ed25519 (OKP) key.");
539
+
540
+ var parts = token.Split('.');
541
+ if (parts.Length != 3)
542
+ throw new SecurityTokenInvalidSignatureException("Malformed JWT — expected 3 segments.");
543
+
544
+ var signingInput = Encoding.ASCII.GetBytes($"{parts[0]}.{parts[1]}");
545
+ var signature = Base64UrlEncoder.DecodeBytes(parts[2]);
546
+ var publicKey = new Ed25519PublicKeyParameters(Base64UrlEncoder.DecodeBytes(jwk.X), 0);
547
+
548
+ var verifier = new Ed25519Signer();
549
+ verifier.Init(forSigning: false, publicKey);
550
+ verifier.BlockUpdate(signingInput, 0, signingInput.Length);
551
+
552
+ if (!verifier.VerifySignature(signature))
553
+ throw new SecurityTokenInvalidSignatureException("Ed25519 signature verification failed.");
554
+
555
+ // MUST return Microsoft.IdentityModel.JsonWebTokens.JsonWebToken — ASP.NET Core 8+
556
+ // uses JsonWebTokenHandler internally. Returning a
557
+ // System.IdentityModel.Tokens.Jwt.JwtSecurityToken here fails with IDX10506
558
+ // ("SignatureValidator returned a token of an unexpected type").
559
+ return jwt;
560
+ };
561
+ });
562
+
563
+ services.AddAuthorization();
564
+
565
+ return services;
566
+ }
567
+ }
288
568
  ```
289
569
 
290
570
  ### Protected Endpoints
@@ -298,7 +578,7 @@ app.MapGet("/api/profile", (ClaimsPrincipal user) =>
298
578
  }).RequireAuthorization();
299
579
  ```
300
580
 
301
- ### appsettings.json
581
+ ### appsettings.Development.json (mandatory, public value)
302
582
 
303
583
  ```json
304
584
  {
@@ -308,6 +588,30 @@ app.MapGet("/api/profile", (ClaimsPrincipal user) =>
308
588
  }
309
589
  ```
310
590
 
591
+ This is **not optional** and **not a secret** — it's the Neon Auth endpoint URL, safe to commit.
592
+ Without it, `NeonAuthOptions.BaseUrl` resolves to an empty string, the JWKS fetch fails, and every
593
+ request is rejected the same way a broken signature validator would fail: a bare `401` with nothing
594
+ in the logs pointing at the real cause. Add it to `appsettings.Development.json` specifically (not only
595
+ `appsettings.json`) when scaffolding the backend — it's easy to add the entry to one and forget the
596
+ other, and the app only fails in the environment missing it.
597
+
598
+ ### Program.cs Usage
599
+
600
+ ```csharp
601
+ var builder = WebApplication.CreateBuilder(args);
602
+
603
+ builder.Services.AddNeonAuthentication(builder.Configuration);
604
+
605
+ var app = builder.Build();
606
+
607
+ app.UseAuthentication();
608
+ app.UseAuthorization();
609
+
610
+ app.MapControllers().RequireAuthorization();
611
+
612
+ app.Run();
613
+ ```
614
+
311
615
  ---
312
616
 
313
617
  ## Row-Level Security Integration
@@ -360,7 +664,32 @@ Each Neon branch gets its own isolated auth data. This means:
360
664
  3. **Use `auth.user_id()` for RLS** — never extract JWT claims manually in SQL
361
665
  4. **Configure OAuth in Neon Console** — not in code
362
666
  5. **Use `SignedIn` / `RedirectToSignIn`** for client-side protection
363
- 6. **Use middleware** for broad route protection patterns
667
+ 6. **Use middleware** for broad route protection patterns — and force `runtime = 'nodejs'` (Edge times
668
+ out minting the session-data cookie)
669
+ 7. **Register every dev/preview origin in `trusted_origins`** before testing login, not after it fails
670
+ 8. **Never share a route-prefix constant** between server-direct backend calls and client-side proxy
671
+ calls — use two named constants (`BACKEND_API_PREFIX` / `PROXY_API_PREFIX`)
672
+ 9. **Verify DI registration for every service injected into an endpoint lambda** — an unregistered
673
+ service silently becomes a phantom `[FromBody]` parameter and crashes the app at startup
674
+ 10. **On .NET, never rely on `options.Authority` alone** — Neon Auth has no OIDC discovery document;
675
+ fetch and cache the JWKS yourself and verify EdDSA signatures manually (BouncyCastle)
676
+
677
+ ---
678
+
679
+ ## Known Failure Modes (Postmortem)
680
+
681
+ Real bugs hit during the `catalogo-produtos-crud` end-to-end run (MORPH_GHLBrain, task T16,
682
+ 2026-07-13). Each one now has a permanent fix in this standard — this table exists so a review can
683
+ scan it in one pass instead of rediscovering these by trial and error.
684
+
685
+ | # | Symptom | Root cause | Fixed in |
686
+ |---|---------|------------|----------|
687
+ | 1 | App crashes at startup on a specific endpoint | Custom service injected into an endpoint lambda without DI registration — Minimal API infers it as a second `[FromBody]` parameter | [DI Registration Checklist](#di-registration-checklist-do-this-before-marking-any-auth-task-done) |
688
+ | 2 | A route works from the server but 404s from the client (or vice versa) | Single shared prefix constant reused for both the direct backend call and the same-origin proxy call | [API Prefix Convention](#api-prefix-convention--server-direct-vs-client-proxy) |
689
+ | 3 | Session never sticks after a full-page navigation, no error shown | New dev/preview origin not registered in Neon Auth `trusted_origins` | [Onboarding Checklist](#onboarding-checklist--do-this-before-testing-login-mandatory) |
690
+ | 4 | Valid session still bounces back to sign-in on `next dev` | `middleware.ts` runs on Edge runtime by default; the session-data cookie mint call times out there | [Route Protection (Middleware)](#route-protection-middleware) |
691
+ | 5 | Every legitimate request gets a bare `401`, no useful log | `Microsoft.IdentityModel.Tokens` can't verify EdDSA/OKP signatures — `IssuerSigningKeyResolver` never finds a usable key | [.NET Backend Integration](#net-backend-integration) |
692
+ | 6 | Same bare-`401` symptom as #5, different cause | `NeonAuth:BaseUrl` missing from `appsettings.Development.json` | [appsettings.Development.json](#appsettingsdevelopmentjson-mandatory-public-value) |
364
693
 
365
694
  ---
366
695
 
@@ -369,6 +698,8 @@ Each Neon branch gets its own isolated auth data. This means:
369
698
  - [Neon Auth Documentation](https://neon.com/docs/auth)
370
699
  - [Neon Auth Authentication Flow](https://neon.com/docs/auth/authentication-flow)
371
700
  - [Migration from Legacy Auth](https://neon.com/docs/auth/migrate/from-legacy-auth)
701
+ - [RFC 8037 — EdDSA for JOSE](https://www.rfc-editor.org/rfc/rfc8037) — `alg: "EdDSA"`, `kty: "OKP"`, `crv: "Ed25519"`
702
+ - [BouncyCastle.Cryptography (NuGet)](https://www.nuget.org/packages/BouncyCastle.Cryptography)
372
703
 
373
704
  ---
374
705
 
@@ -74,12 +74,17 @@ src/app/
74
74
 
75
75
  ## Server Component Data Fetch Pattern
76
76
 
77
+ Server Components call the .NET backend directly — use a dedicated `BACKEND_API_PREFIX` constant, never
78
+ the same-origin proxy prefix Client Components use. See `frontend/nextjs/data-fetching.md` →
79
+ "Common Mistakes" and `backend/integrations/neon-auth/neon-auth.md` → "API Prefix Convention".
80
+
77
81
  ```tsx
78
82
  // app/(dashboard)/users/page.tsx
79
83
  import { UserList } from '@/features/users/components/user-list';
84
+ import { BACKEND_API_PREFIX } from '@/lib/api/prefixes'; // server-direct prefix ONLY
80
85
 
81
86
  async function getUsers(): Promise<User[]> {
82
- const res = await fetch(`${process.env.API_URL}/api/users`, {
87
+ const res = await fetch(`${BACKEND_API_PREFIX}/users`, {
83
88
  next: { revalidate: 60 },
84
89
  });
85
90
  if (!res.ok) throw new Error('Failed to fetch users');
@@ -51,12 +51,19 @@ export const userKeys = {
51
51
 
52
52
  ## useQuery Hook Pattern
53
53
 
54
+ Client Components never call the .NET backend directly — they call a same-origin proxy route. Use a
55
+ dedicated `PROXY_API_PREFIX` constant for this, never the backend's own prefix (see
56
+ [Common Mistakes](#common-mistakes) and `backend/integrations/neon-auth/neon-auth.md` →
57
+ "API Prefix Convention" for why sharing one constant between server-direct and proxy calls silently
58
+ breaks one of the two at runtime).
59
+
54
60
  ```ts
55
61
  // features/users/hooks/use-users.ts
56
62
  import { useQuery } from '@tanstack/react-query';
57
63
  import { userKeys } from './query-keys';
58
64
  import { z } from 'zod';
59
65
  import { userSchema } from '@/features/users/types/user.schemas';
66
+ import { PROXY_API_PREFIX } from '@/lib/api/prefixes'; // client-proxy prefix ONLY — never BACKEND_API_PREFIX here
60
67
 
61
68
  const usersResponseSchema = z.array(userSchema);
62
69
 
@@ -64,7 +71,7 @@ export function useUsers() {
64
71
  return useQuery({
65
72
  queryKey: userKeys.lists(),
66
73
  queryFn: async () => {
67
- const res = await fetch('/api/proxy/users');
74
+ const res = await fetch(`${PROXY_API_PREFIX}/users`);
68
75
  if (!res.ok) throw new Error('Failed to fetch users');
69
76
  return usersResponseSchema.parse(await res.json());
70
77
  },
@@ -79,12 +86,13 @@ export function useUsers() {
79
86
  import { useMutation, useQueryClient } from '@tanstack/react-query';
80
87
  import { userKeys } from './query-keys';
81
88
  import type { CreateUserInput } from '@/features/users/types/user.types';
89
+ import { PROXY_API_PREFIX } from '@/lib/api/prefixes';
82
90
 
83
91
  export function useCreateUser() {
84
92
  const queryClient = useQueryClient();
85
93
  return useMutation({
86
94
  mutationFn: async (input: CreateUserInput) => {
87
- const res = await fetch('/api/proxy/users', {
95
+ const res = await fetch(`${PROXY_API_PREFIX}/users`, {
88
96
  method: 'POST',
89
97
  headers: { 'Content-Type': 'application/json' },
90
98
  body: JSON.stringify(input),
@@ -101,10 +109,15 @@ export function useCreateUser() {
101
109
 
102
110
  ## Server Component + TanStack Query Handoff
103
111
 
112
+ Server Components call the .NET backend directly — use `BACKEND_API_PREFIX`, never the client-proxy
113
+ constant, and never a single shared `API_PREFIX` reused for both call sites.
114
+
104
115
  ```tsx
105
116
  // app/(dashboard)/users/page.tsx — Server Component: initial fetch
117
+ import { BACKEND_API_PREFIX } from '@/lib/api/prefixes'; // server-direct prefix ONLY — never PROXY_API_PREFIX here
118
+
106
119
  export default async function UsersPage() {
107
- const initialUsers = await fetch(`${process.env.API_URL}/api/users`).then(r => r.json());
120
+ const initialUsers = await fetch(`${BACKEND_API_PREFIX}/users`).then(r => r.json());
108
121
  return <UserListClient initialData={initialUsers} />;
109
122
  }
110
123
 
@@ -124,6 +137,7 @@ export function UserListClient({ initialData }: { initialData: User[] }) {
124
137
  | No Zod on API response | `schema.parse(await res.json())` | Type safety at runtime |
125
138
  | Hardcoded query keys: `['users']` | Key factory: `userKeys.lists()` | Consistent invalidation |
126
139
  | `queryClient.invalidateQueries(['users'])` | `{ queryKey: userKeys.lists() }` | Object syntax required in v5 |
140
+ | One shared `API_PREFIX` for server-direct AND client-proxy fetches | Two named constants: `BACKEND_API_PREFIX` / `PROXY_API_PREFIX` | Silent runtime break on whichever side drifts — no build-time signal |
127
141
 
128
142
  ---
129
143
 
@@ -648,9 +648,9 @@
648
648
  "category": "integration",
649
649
  "phase": "implement",
650
650
  "required": false,
651
- "description": "Neon Auth (Better Auth) authentication configuration for .NET",
651
+ "description": "Neon Auth (Better Auth) EdDSA/Ed25519 JWT validation for .NET via BouncyCastle — cached JWKS provider + manual SignatureValidator (no OIDC discovery available)",
652
652
  "outputName": "NeonAuthConfig.cs",
653
- "placeholders": [],
653
+ "placeholders": ["Namespace"],
654
654
  "stackSpecific": false,
655
655
  "engine": "handlebars",
656
656
  "deprecated": false,