@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.
- package/README.md +4 -4
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +5 -5
- package/docs/QUICKSTART.md +4 -4
- package/framework/CLAUDE.md +1 -1
- package/framework/MORPH.md +2 -2
- package/framework/agents.json +4 -2
- package/framework/evals/rubrics/architecture.md +27 -0
- package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
- package/framework/skills/core/morph-apply/SKILL.md +13 -8
- package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-implement/SKILL.md +13 -8
- package/framework/skills/core/morph-plan/SKILL.md +1 -1
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +19 -7
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/framework/standards/STANDARDS.json +14 -3
- package/framework/standards/backend/integrations/neon-auth/neon-auth.md +350 -19
- package/framework/standards/frontend/nextjs/app-router.md +6 -1
- package/framework/standards/frontend/nextjs/data-fetching.md +17 -3
- package/framework/templates/REGISTRY.json +2 -2
- package/framework/templates/integrations/neon-auth-config.cs +144 -11
- package/package.json +1 -1
- package/src/commands/project/doctor.js +1 -1
- package/src/utils/version-checker.js +2 -2
|
@@ -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-
|
|
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
|
|
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
|
-
//
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
277
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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(`${
|
|
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(
|
|
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(
|
|
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(`${
|
|
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)
|
|
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,
|