@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 +2 -2
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- 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 +1 -1
- 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 +1 -1
- 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 +10 -1
- 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/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.
|
|
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.
|
|
368
|
+
*morph-spec v7.9.3 by [Polymorphism Tech](https://polymorphism.tech)*
|
package/claude-plugin.json
CHANGED
package/docs/CHEATSHEET.md
CHANGED
package/docs/QUICKSTART.md
CHANGED
package/framework/agents.json
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
7
|
+
cliVersion: "7.9.3"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-eval
|
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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:
|
|
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-
|
|
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,
|
|
@@ -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
|
|
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
|
-
///
|
|
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
|
|
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
|
-
|
|
55
|
-
|
|
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
|
|
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>();
|