@polymorphism-tech/morph-spec 7.9.2 → 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 CHANGED
@@ -3,7 +3,7 @@
3
3
  > Spec-driven development framework for multi-stack projects. Turns feature requests into implementation-ready code through structured, AI-orchestrated phases.
4
4
 
5
5
  **Package:** `@polymorphism-tech/morph-spec`
6
- **Version:** 7.9.2
6
+ **Version:** 7.9.3
7
7
  **Requires:** Node.js 22+, Claude Code
8
8
 
9
9
  ---
@@ -365,4 +365,4 @@ Code generated by morph-spec (contracts, templates, implementation output) belon
365
365
 
366
366
  ---
367
367
 
368
- *morph-spec v7.9.2 by [Polymorphism Tech](https://polymorphism.tech)*
368
+ *morph-spec v7.9.3 by [Polymorphism Tech](https://polymorphism.tech)*
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "morph-spec",
3
- "version": "7.9.2",
3
+ "version": "7.9.3",
4
4
  "displayName": "MORPH-SPEC Framework",
5
5
  "description": "Spec-driven development with 38 agents and 8-phase workflow for .NET/Blazor/Next.js/Azure",
6
6
  "publisher": "polymorphism-tech",
@@ -200,4 +200,4 @@ These files are never edited directly. Use CLI commands or `morph-spec update` i
200
200
 
201
201
  ---
202
202
 
203
- *morph-spec v7.9.2 by Polymorphism Tech*
203
+ *morph-spec v7.9.3 by Polymorphism Tech*
@@ -197,4 +197,4 @@ morph-spec doctor
197
197
 
198
198
  ---
199
199
 
200
- *morph-spec v7.9.2 by Polymorphism Tech*
200
+ *morph-spec v7.9.3 by Polymorphism Tech*
@@ -19,7 +19,8 @@
19
19
  "architecture-vertical-slice-vertical-slice",
20
20
  "backend-dotnet-result-pattern",
21
21
  "backend-dotnet-vsa-handler-patterns",
22
- "backend-dotnet-fluent-validation-vsa"
22
+ "backend-dotnet-fluent-validation-vsa",
23
+ "backend-integrations-neon-auth-neon-auth"
23
24
  ],
24
25
  "mcp": ["context7"],
25
26
  "persona": "Você é um engenheiro .NET sênior. Pensa em VSA com handlers diretos, result pattern, sealed classes, Guid.CreateVersion7(). Acesso a dados: ApplicationDbContext injetado direto no slice (DbSet<T> já é repository, DbContext já é unit of work); para concorrência ou SQL específico, store dedicado hand-written com justificativa em decisions.md; nunca uma camada de Service/Manager; nunca repository genérico (IRepository<T>/IUnitOfWork). Não usa exceptions como fluxo. Consulta o standard antes de escrever cada slice. Nomeia tudo em PascalCase para tipos, camelCase para parâmetros."
@@ -81,7 +82,8 @@
81
82
  "frontend-nextjs-components",
82
83
  "frontend-nextjs-data-fetching",
83
84
  "frontend-nextjs-naming-conventions",
84
- "frontend-nextjs-state-management"
85
+ "frontend-nextjs-state-management",
86
+ "backend-integrations-neon-auth-neon-auth"
85
87
  ],
86
88
  "mcp": ["context7"],
87
89
  "persona": "Você escreve Next.js App Router idiomático. Server Components por padrão, Client Components apenas quando há interatividade real. Tailwind + Shadcn + Framer Motion. Tipos via Zod. Nada de getServerSideProps. RSC streaming quando faz sentido."
@@ -2,6 +2,27 @@
2
2
 
3
3
  Avalia se a implementação segue o padrão arquitetural definido no spec.md e mandate.md.
4
4
 
5
+ ## Verificações mecânicas obrigatórias (auto-reprovação se falhar)
6
+
7
+ Estas duas checagens são objetivas — não dependem de julgamento sobre estilo — e cobrem bugs reais que
8
+ já derrubaram uma app em produção (ver `backend/integrations/neon-auth/neon-auth.md` → "Known Failure
9
+ Modes"). Rode ambas sempre que o diff tocar endpoints minimal API ou fetch de dados cross-camada, mesmo
10
+ que o mandate não as mencione explicitamente. Uma violação em qualquer uma delas é bloqueante — não
11
+ "ressalva" — independente do resto do score de arquitetura.
12
+
13
+ 1. **DI de todo serviço injetado em endpoint.** Para cada parâmetro de tipo serviço em uma lambda de
14
+ endpoint minimal API (qualquer tipo que não seja `TRequest`, `CancellationToken`, `ClaimsPrincipal`
15
+ ou um primitivo de rota/query), confirme que existe `AddScoped<T>()` / `AddSingleton<T>()` /
16
+ `AddTransient<T>()` correspondente em `Program.cs` (ou registro via assembly-scanning). Um serviço
17
+ não registrado é inferido como `[FromBody]` pelo Minimal API e derruba a app inteira no startup —
18
+ isso é "Score 1-2", não "Score 5-6 com ressalva", porque não é estilo: é a aplicação não subir.
19
+ 2. **Nenhum prefixo de rota reusado para dois propósitos.** Se o projeto tem chamadas server-direct ao
20
+ backend E chamadas client-side via proxy same-origin, confirme que existem DUAS constantes
21
+ nomeadas distintas (uma por propósito) e que nenhuma delas aparece do lado errado (grep: arquivos de
22
+ Server Component só importam a constante server-direct; hooks/Client Component só importam a
23
+ constante de proxy). Uma única constante reusada para os dois quebra silenciosamente um dos dois
24
+ caminhos em runtime, sem sinal em build — também bloqueante.
25
+
5
26
  ## Score 9-10 — Exemplar
6
27
 
7
28
  - Segue o padrão de feature folder rigorosamente: cada feature tem seus próprios handlers, commands/queries, validators e endpoints.
@@ -36,6 +57,8 @@ Avalia se a implementação segue o padrão arquitetural definido no spec.md e m
36
57
  - Implementação plana sem estrutura de feature.
37
58
  - Dependências circulares ou acoplamento total entre camadas.
38
59
  - Requer refatoração antes de qualquer entrega.
60
+ - Qualquer violação das "Verificações mecânicas obrigatórias" acima (DI faltando, prefixo de rota
61
+ reusado) — teto de score 2 independente de quão bem o resto segue o padrão.
39
62
 
40
63
  ## Perguntas de avaliação
41
64
 
@@ -44,3 +67,7 @@ Avalia se a implementação segue o padrão arquitetural definido no spec.md e m
44
67
  3. Objetos de domínio importam de infraestrutura (ORM, HTTP, I/O)?
45
68
  4. Se remover esta feature, alguma outra quebra?
46
69
  5. O padrão arquitetural descrito no spec.md foi seguido?
70
+ 6. Todo serviço injetado numa lambda de endpoint minimal API tem `AddScoped`/`AddSingleton`/
71
+ `AddTransient` correspondente em `Program.cs`?
72
+ 7. Se o diff tem chamada server-direct ao backend E chamada client-side via proxy para o mesmo
73
+ recurso, elas usam duas constantes de prefixo distintas — nenhuma reusada para o outro propósito?
@@ -4,7 +4,7 @@ description: Guia a criação de uma landing page nova ponta-a-ponta a partir do
4
4
  version: core
5
5
  when-to-invoke: ao iniciar um novo projeto de LP com o template Umbraco genérico
6
6
  user-invokable: true
7
- cliVersion: "7.9.2"
7
+ cliVersion: "7.9.3"
8
8
  ---
9
9
 
10
10
  # create-umbraco-lp
@@ -4,7 +4,7 @@ description: Migra uma landing page existente (React/Vite, Next.js estático ou
4
4
  version: core
5
5
  when-to-invoke: ao receber instrução "use /migrate-lp-to-umbraco {projeto}" do usuário, ou quando o usuário descrever necessidade de migrar uma LP para Umbraco
6
6
  user-invokable: true
7
- cliVersion: "7.9.2"
7
+ cliVersion: "7.9.3"
8
8
  ---
9
9
 
10
10
  # migrate-lp-to-umbraco
@@ -4,7 +4,7 @@ description: Orquestra a execução autônoma de tasks de uma feature morph-spec
4
4
  version: core
5
5
  when-to-invoke: após Gate 2 aprovado, ao receber instrução "use /morph-apply" ou "implementar feature"
6
6
  user-invokable: true
7
- cliVersion: "7.9.2"
7
+ cliVersion: "7.9.3"
8
8
  ---
9
9
 
10
10
  # morph-apply
@@ -4,7 +4,7 @@ description: Entende o negócio, os usuários e a arquitetura antes de qualquer
4
4
  version: core
5
5
  when-to-invoke: ao receber um novo request de feature, antes de morph-plan
6
6
  user-invokable: true
7
- cliVersion: "7.9.2"
7
+ cliVersion: "7.9.3"
8
8
  ---
9
9
 
10
10
  # morph-brainstorming
@@ -4,7 +4,7 @@ description: Avalia a qualidade de uma tarefa ou fase com rubricas graduadas 0-1
4
4
  version: core
5
5
  when-to-invoke: ao completar uma tarefa ou ao finalizar uma fase de implementação
6
6
  user-invokable: false
7
- cliVersion: "7.9.2"
7
+ cliVersion: "7.9.3"
8
8
  ---
9
9
 
10
10
  # morph-eval
@@ -4,7 +4,7 @@ description: Guia a execução de cada tarefa de implementação com standard e
4
4
  version: core
5
5
  when-to-invoke: ao iniciar cada task de implementação listada em tasks.json
6
6
  user-invokable: false
7
- cliVersion: "7.9.2"
7
+ cliVersion: "7.9.3"
8
8
  ---
9
9
 
10
10
  # morph-implement
@@ -4,7 +4,7 @@ description: Guia a produção dos três artefatos de planejamento de uma featur
4
4
  version: core
5
5
  when-to-invoke: ao iniciar a fase plan de uma feature, após o Gate 1 (proposal aprovado)
6
6
  user-invokable: true
7
- cliVersion: "7.9.2"
7
+ cliVersion: "7.9.3"
8
8
  ---
9
9
 
10
10
  # morph-plan
@@ -4,7 +4,7 @@ description: Orquestra a fase de proposta+plano de uma feature morph-spec. Encad
4
4
  version: core
5
5
  when-to-invoke: ao receber instrução "use /morph-proposal" do usuário, ou ao iniciar uma nova feature
6
6
  user-invokable: true
7
- cliVersion: "7.9.2"
7
+ cliVersion: "7.9.3"
8
8
  ---
9
9
 
10
10
  # morph-proposal
@@ -4,7 +4,7 @@ description: Conduz a fase de revisão e produz o review-report.md para o Gate 3
4
4
  version: core
5
5
  when-to-invoke: ao iniciar a fase review, após todas as tasks de tasks.json estarem com status `done`
6
6
  user-invokable: true
7
- cliVersion: "7.9.2"
7
+ cliVersion: "7.9.3"
8
8
  ---
9
9
 
10
10
  # morph-review
@@ -33,6 +33,15 @@ e o `recap.md` estar atualizado. Normalmente invocada pela skill orquestradora `
33
33
  - Se não atendido: registre como "bloqueante" com descrição do gap.
34
34
  - Se parcialmente atendido: registre como "pendência" com o que falta.
35
35
 
36
+ **Verificações mecânicas sempre-rodar** (independente do mandate mencionar ou não — cobrem bugs
37
+ reais já vistos em produção, ver `framework/evals/rubrics/architecture.md` → "Verificações
38
+ mecânicas obrigatórias"), quando o diff tocar endpoints minimal API ou fetch cross-camada:
39
+ - Todo serviço injetado numa lambda de endpoint tem `AddScoped`/`AddSingleton`/`AddTransient`
40
+ correspondente em `Program.cs`. Falha aqui é bloqueante — a app não sobe.
41
+ - Se há chamada server-direct ao backend e chamada client-side via proxy para o mesmo recurso,
42
+ confirme que usam duas constantes de prefixo nomeadas distintas, nenhuma reusada para o
43
+ outro propósito. Falha aqui é bloqueante — quebra silenciosa em runtime, não em build.
44
+
36
45
  4. **Despache o avaliador independente** — o score do Gate 3 nunca é auto-atribuído por quem implementou:
37
46
  - Rode a suíte de testes do projeto e capture a **saída real** (não um resumo seu).
38
47
  - Gere o **diff da implementação** (`git diff` do range da feature, ou a lista de arquivos do recap.md com seus conteúdos atuais).
@@ -4,7 +4,7 @@ description: Varre o codebase existente, detecta stack atual, compara com o stac
4
4
  version: core
5
5
  when-to-invoke: ao iniciar uma feature em projeto brownfield (codebase já existente)
6
6
  user-invokable: true
7
- cliVersion: "7.9.2"
7
+ cliVersion: "7.9.3"
8
8
  ---
9
9
 
10
10
  # morph-stack-scan
@@ -4,7 +4,7 @@ description: Localiza e carrega um standard específico do STANDARDS.json sob de
4
4
  version: core
5
5
  when-to-invoke: quando precisar consultar um standard específico durante a implementação
6
6
  user-invokable: true
7
- cliVersion: "7.9.2"
7
+ cliVersion: "7.9.3"
8
8
  ---
9
9
 
10
10
  # morph-standards
@@ -933,15 +933,26 @@
933
933
  "authentication",
934
934
  "OAuth",
935
935
  "JWT",
936
- "session management"
936
+ "session management",
937
+ "trusted origins",
938
+ "EdDSA",
939
+ "Ed25519",
940
+ "BouncyCastle",
941
+ "signature validator",
942
+ "jwks",
943
+ "middleware runtime",
944
+ "api prefix"
937
945
  ],
938
946
  "anchors": {
939
947
  "setup": "#installation--setup",
948
+ "onboarding": "#onboarding-checklist--do-this-before-testing-login-mandatory",
940
949
  "middleware": "#route-protection-middleware",
950
+ "api-prefix": "#api-prefix-convention--server-direct-vs-client-proxy",
941
951
  "dotnet": "#net-backend-integration",
942
- "rls": "#row-level-security-integration"
952
+ "rls": "#row-level-security-integration",
953
+ "postmortem": "#known-failure-modes-postmortem"
943
954
  },
944
- "digest": "Use @neondatabase/auth SDK for Next.js. Auth data lives in neon_auth schema — no external provider, no webhooks. Server: createNeonAuth() with NEON_AUTH_BASE_URL + NEON_AUTH_COOKIE_SECRET. Client: createAuthClient(). Pages: AuthView component. Protection: auth.middleware(). .NET: JWT Bearer with Neon Auth authority, NameClaimType=sub. RLS: auth.user_id() via pg_session_jwt."
955
+ "digest": "Use @neondatabase/auth SDK for Next.js. Auth data lives in neon_auth schema — no external provider, no webhooks. Server: createNeonAuth() with NEON_AUTH_BASE_URL + NEON_AUTH_COOKIE_SECRET. Client: createAuthClient(). Pages: AuthView component. Protection: auth.middleware() with runtime='nodejs' (Edge times out minting the session cookie). Register every dev/preview origin in trusted_origins before testing login. Two named prefix constants (BACKEND_API_PREFIX / PROXY_API_PREFIX) — never share one between server-direct and client-proxy calls. .NET: tokens are EdDSA/Ed25519 (OKP) — Microsoft.IdentityModel.Tokens can't verify OKP keys and there's no OIDC discovery; fetch+cache the JWKS yourself and verify signatures manually via BouncyCastle.Cryptography + TokenValidationParameters.SignatureValidator (must return Microsoft.IdentityModel.JsonWebTokens.JsonWebToken, not JwtSecurityToken — else IDX10506). NeonAuth:BaseUrl is mandatory in appsettings.Development.json (public value). RLS: auth.user_id() via pg_session_jwt."
945
956
  },
946
957
  {
947
958
  "id": "backend-integrations-hangfire-hangfire-jobs",
@@ -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,
@@ -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.2",
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",