feat: supported session-state read for the embedding app's own chrome - #717
Conversation
The widgets read GET /sessions/me internally, but an embedding app's own
header had no documented way to the same answer and kept rendering
signed-out CTAs beside a live session (2026-08-02 Wicklore rerun).
- @zitadel/sdk-next/session: new entry with getSession(), a client-side
read of the same-origin {proxyPath}/sessions/me — works on any page,
no middleware-matcher requirement, returns the client-safe
ClientAuthResult (no token). 401/404/anonymous map to signed out;
other failures throw.
- Client-safe auth shapes lifted to @zitadel/sdk-core as single source;
sdk-nuxt re-exports unchanged, so useAuth() and getSession() return
the identical shape.
- Scaffold guidance (AGENTS.md managed section) + generated profile-page
comments now name each framework's session read path (Next getSession,
Nuxt useAuth, SPA raw proxy read).
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
🦋 Changeset detectedLatest commit: 2331d87 The changes in this PR will be included in the next version bump. This PR includes changesets to release 20 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
There was a problem hiding this comment.
Pull request overview
This PR adds a supported, framework-appropriate way for embedding apps to read session state for their own UI chrome (headers/account menus), aligning Next and Nuxt on a shared client-safe auth shape while updating scaffold guidance and templates accordingly.
Changes:
- Introduces
@zitadel/sdk-next/sessionwithgetSession()(browser-only, same-origin{proxyPath}/sessions/meread) and exports client-safeClientAuthResulttypes. - Moves client-safe auth result types (
ClientSession/ClientAuthState/ClientAuthResult) into@zitadel/sdk-coreand re-exports them fromsdk-nuxtandsdk-next. - Updates CLI scaffold guidance/tests and framework templates to document the supported session-state read per framework (Next
getSession(), NuxtuseAuth(), SPA raw proxy fetch).
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| packages/sdk-nuxt/src/runtime/types.ts | Re-exports client-safe auth types from sdk-core instead of defining Nuxt-local copies. |
| packages/sdk-next/src/types.ts | Re-exports the shared client-safe auth types from sdk-core for Next usage. |
| packages/sdk-next/src/session.ts | Adds getSession() client-side helper and re-exports client-safe auth types from the session entry. |
| packages/sdk-next/src/index.ts | Exposes getSession() / GetSessionOptions from the main entry in addition to the dedicated subpath. |
| packages/sdk-next/src/tests/session.test.ts | Adds unit tests covering getSession() status handling, mapping, and config precedence. |
| packages/sdk-next/README.md | Documents “Session state for your own UI” using @zitadel/sdk-next/session. |
| packages/sdk-next/package.json | Adds ./session export and includes src/session.ts in the build entry list. |
| packages/sdk-core/src/middleware.ts | Defines shared client-safe auth result types in the single source of truth. |
| apps/cli/tests/unit/lib/orca/patchers/rule/guidance.test.ts | Pins the per-framework guidance branching (Next vs Nuxt vs SPA frameworks). |
| apps/cli/src/lib/orca/patchers/rule/nuxt/templates.ts | Adds a Nuxt template comment pointing app chrome to useAuth() for session state. |
| apps/cli/src/lib/orca/patchers/rule/next/renderers/react/index.ts | Adds a Next template comment pointing app chrome to getSession() from the new subpath. |
| apps/cli/src/lib/orca/patchers/rule/guidance.ts | Adds a framework-specific guidance paragraph describing the supported session-state read path. |
| .changeset/session-state-for-app-chrome.md | Adds release notes and version bumps for sdk-next/sdk-core/cli (minor) and sdk-nuxt (patch). |
Review round (Copilot + codex): strip trailing slashes from proxyPath (matches the typed client's base-URL normalization); treat 404 as signed-out only when it is the backend's JSON error — a framework's HTML 404 from a misrouted proxy now throws; README/JSDoc header example keeps unknown/error states instead of catching failures into 'Sign in'.
|
Codex P2 (example swallows the failures the helper preserves) — agreed and fixed in 2fd9d18: the header example in both the README and the Same commit also hardens the read itself beyond Copilot's trailing-slash point: a 404 maps to signed-out only when it is the backend's JSON error — a framework router's HTML 404 (e.g. from a matcher miss on a malformed proxy path) now throws instead of quietly rendering "Sign in", which was this PR's original sin to fix. Branch updated with main via merge (8b75248, no force-push) and re-validated on the merged head: cli 957/957, sdk-next 108/108, lint/typecheck green (a stale-lockfile 127 cascade after the merge was the local worktree needing |
…g-lalande-218efe Resolution notes: took main's #717-final session.ts/session.test.ts/changeset (JSON-guarded 404, proxyPath normalization, unknown-vs-signed-out example), re-applied the .js-extension policy to session.ts, kept the tsc per-file build and /react wiring, preserved main's next>=15 peer floor.
…loads (#718) Was stacked on #717 (merged — now targeting `main`). Closes the two sdk-next hardening findings from the getSession session-state work, plus a Codex review round (provider client-boundary leak, `opaqueTokenTimeoutMs`). ## Summary - **`auth()` verifies instead of trusts.** It previously `decodeJwt()`ed the `x-nextgen-auth-token` header without signature verification and treated *any* other non-empty value as a middleware-validated opaque token — so on any route outside the middleware `matcher` (the scaffolded matcher covers only the proxy path and protected routes), a client-supplied header spoofed any identity, and even `x-nextgen-auth-token: garbage` returned `isAuthenticated: true`. `auth()` now re-verifies every value it reads: JWT-shaped tokens get full `verifyJwt` (JWKS signature, `iss`, `exp`, alg/typ allow-lists — same rules and defaults as the middleware, in-process key cache), opaque tokens are validated against `GET /sessions/me` (React `cache()`-deduplicated per render pass, fail-closed with a `console.warn` that distinguishes forged headers from JWKS/backend reachability problems). Opaque sessions now also return the real identity from the backend response instead of `userId: "unknown"`. New optional `AuthOptions` (a `Pick` of the middleware options) for apps that customise verification; the docstring now states the matcher precondition instead of claiming the middleware always validated the header. - **`NextgenProvider` strips the token server-side — and is server-only.** The README's documented pattern (`await auth()` into the provider) serialised the raw session token into the RSC flight payload, readable by any client script. The provider now converts to the client-safe `ClientAuthResult` (from #717, explicit field pick) **before** the value crosses the server→client boundary. Per the Codex P1 finding, the provider is additionally guarded with `server-only` and exported from `@zitadel/sdk-next/server` (+ root), *not* `/react`: wrapping it in a `"use client"` file (the common `providers.tsx` pattern) would make the wrapper the boundary and serialise the still-unstripped prop — that wrapper is now a build error instead of a silent leak. Client-seeded trees (e.g. from `getSession()`) use the new `AuthContextProvider` on `/react`, which only accepts the token-less shape. `useAuth()` returns `ClientAuthResult`, matching sdk-nuxt and `getSession()`. - **Client entry + root-barrel guard.** New `@zitadel/sdk-next/react` entry (`useAuth`, `AuthContextProvider`, client-safe types); `auth.ts` imports `server-only` (already a dependency, previously unwired), so pulling `auth()` into a `"use client"` graph fails the build with an import trace. README gains an entry-point table; `sideEffects` declared (`client.js` for element registration, `auth.js`/`provider.js` to keep the guard imports unprunable). - **`opaqueTokenTimeoutMs` honoured (Codex P2 — ran deeper than reported):** the option already existed in sdk-core's `NextgenMiddlewareOptions` and sdk-nuxt honours it, but *both* sdk-next validation passes coupled the `GET /sessions/me` timeout to `jwksTimeoutMs` — the middleware's opaque fallback (pre-existing bug) and the new `auth()` (which copied it faithfully). Both now thread `opaqueTokenTimeoutMs` (default 5000 ms), it's part of `AuthOptions`, and the README options table documents it. - **Build correctness (found while wiring the above):** tsup's chunk splitting hoisted the compiled `createContext` module into a shared chunk *without* its `"use client"` directive — the published provider/hook entries could never have worked from a real consumer (nothing in-repo exercised them, which is how it survived). The package now builds with per-file `tsc` emit like sdk-core (directives preserved per module, dep types resolved via built dist by neutralising the `@zitadel/source` condition), relative imports carry `.js` extensions, and demo-next now renders `NextgenProvider` + a `useAuth` badge so the documented pattern compiles in CI forever. - `isJwtShaped` moves to `@zitadel/sdk-core` (shared JWS/JWE structural detection; the middleware imports it from there). ## Validation - `moon run sdk-core:{lint,typecheck,build,test} sdk-next:{lint,typecheck,build,test} demo-next:{lint,typecheck,build} demo-next-e2e:lint` — all green (sdk-next: 132 tests at head; earlier counts in review threads reflect older heads). - New regression tests use real RSA-signed JWTs against a mocked JWKS: forged/unsigned JWT rejected, signed-without-`sub` rejected (previously *authenticated* via the opaque fallthrough), expired rejected, garbage opaque header rejected via mocked 401, backend-confirmed opaque token authenticates with real identity, anonymous/500/unreachable fail closed, and `opaqueTokenTimeoutMs` proven with a signal-respecting fetch mock (abort actually reaches the request). Provider tests assert the context value contains no `token` key for every accepted input shape; `AuthContextProvider` covered for client seeding. - Compile-guard checks in demo-next (temporary routes, since removed): importing `auth` from the root barrel in a `"use client"` page fails the build with the full import trace into `dist/auth.js`; the Codex P1 repro — a `"use client"` wrapper around `NextgenProvider` — fails with `'server-only' cannot be imported from a Client Component module`; the committed subpath-based provider/badge usage builds clean. - `demo-next-e2e/src/auth.spec.ts` now asserts the raw session-cookie value appears **nowhere** in the server's `/admin` response (HTML + inlined RSC flight payload) — a durable leak detector for this class. Lint/typecheck-verified; not executed locally (port 8080 is held by a live local `nextgen` server and the e2e web-server config pins that port; the suite is opt-in local). Worth a run wherever 8080 is free. - `dist/` inspected: `"use client"` heads `context.js`/`useAuth.js`, `provider.js` opens with `import "server-only"` (shared-render deliberately removed), `auth.js` likewise. ## Release notes / changeset `.changeset/verify-auth-header-strip-token.md` — `@zitadel/sdk-next` minor, `@zitadel/sdk-core` minor. Security hardening plus surface changes (`useAuth()` return shape, new `/react` entry, `NextgenProvider` server-only, root barrel build-errors in client graphs, opaque timeout no longer follows `jwksTimeoutMs`). ## Notes - Defense-in-depth cost: on matcher-covered routes the middleware has already validated the token, so `auth()`'s re-verification adds ~one cached-JWKS signature check for JWTs, or one `/sessions/me` round-trip per render pass for opaque tokens (per-render `cache()` dedupe). If that round-trip ever matters, an HMAC attestation minted by the middleware (e.g. keyed off `ZITADEL_PROJECT_SECRET`) could skip it — deliberately out of scope here. - A further evolution would be `auth()` reading the `__nextgen_session` cookie directly and dropping the matcher precondition entirely — but that contradicts the just-landed #717 guidance (which routes app chrome to `getSession()`), so it's flagged rather than done. - Root-barrel client imports of *client* exports (`useAuth`) happen to compile under Turbopack's per-export tree shaking; that's bundler-dependent, so the README documents `/react` and `/session` as the supported client imports rather than blessing it.
## Summary - normalize missing, expired, superseded, and rotated self-session cookies to the canonical `401 auth.unauthorized` contract for both `GET /sessions/me` and `DELETE /sessions/me`, with `Cache-Control: no-store` on every response path - keep the browser-facing Next.js `getSession` helper on the dedicated `@zitadel/sdk-next/session` subpath and require canonical 401/404 envelopes before reporting a signed-out state - attach project credentials only to the exact `POST /sessions/exchange` operation in Next.js, Nuxt, and generated Vite/Angular proxies while preserving caller-provided authorization - surgically migrate only fingerprinted legacy managed proxies, preserve target/rewrite/TLS options and surrounding comments, and make `zitadel doctor` warn instead of certifying unknown or deceptively marked proxy code - merge #718's token verification and provider hardening, resolving its export overlap without restoring `getSession` at the package root ## Validation - `moon run server:test` - `go test -c -tags postgres_integration ./internal/api/integration_test` (container-backed suite compiled locally) - focused CLI proxy/doctor tests: 73 passed - `moon run cli:test`: 105 files / 963 tests passed - `moon run cli:lint`, `moon run cli:typecheck` - merged-tree SDK checks: `sdk-core` (6 tests) and `sdk-next` (7 files / 144 tests), plus lint, typecheck, and build - merged-tree `demo-next` build/typecheck/lint and `demo-next-e2e` lint - `moon run workspace:check -- --only pack` - `moon run workspace:check -- --only release` - `moon run workspace:check -- --only journey` (Next, Nuxt, React, Vue, Angular, Solid, Svelte, and Qwik fresh-app browser journeys) - OpenAPI validation, generated-code checks, Go format/vet/tests, changeset status, and PR-title validation The all-workspace Node aggregate hit one unrelated console test timeout under concurrency; the exact console suite passed 8/8 when rerun in isolation. Postgres and Spanner integration phases could not run locally because Docker was not running, so GitHub Actions remains the source of truth for those container-backed lanes. ## Release notes / changeset Added `.changeset/harden-session-proxy-credentials.md` with patch releases for `@zitadel/cli`, `@zitadel/server`, `@zitadel/sdk-next`, and `@zitadel/sdk-nuxt`. The merged #718 changeset independently carries its release intent; Changesets resolves the combined branch outcome. ## Notes This is the security follow-up to #717. #718 is now merged and its provider/token-verification hardening is retained. Existing apps with a legacy managed Vite or Angular proxy should run `zitadel doctor --fix` after upgrading the CLI. Custom or unrecognized proxy implementations are deliberately left untouched and surfaced for manual review. The implementation was checked against ADR 005 (public runtime/private credentials), ADR 030 (canonical error envelopes), ADR 037 (server-authoritative sessions), and proposed ADR 036 (interim credential boundary). Restricting the confidential project secret to the exact handoff exchange is the immediate hardening step; the publishable-key design remains the target state. The first merged-tree `full-pr` run exposed that the integration test server bypassed the production `WithSessionStateNoStore` wrapper, so its expired-session assertion could not observe the header that production emits. The harness now mirrors that production middleware; the restarted Postgres lane is the runtime proof.
…ession-read plane (#722) ## Summary Amends ADR 036 (API credential planes) in place — status stays Proposed, with an `Amended: 2026-08-03` marker — with three decisions settled while hardening the sdk-next auth surface (#717/#718): - **Credential exposure contracts.** The browser-bundle litmus test generalises to a per-class contract table (publishable key, project secret, session token, handoff token): which surfaces each credential may appear on, and which it must never appear on. The session-token row makes the httpOnly contract explicit — *no server-side surface may re-materialise the token into browser-readable content* (HTML/DOM, serialised SSR/RSC payloads, client state, URLs, logs) — and cites the wired enforcement: sdk-nuxt's SSR-payload strip, sdk-next's server-only `NextgenProvider` strip, and the demo-next e2e leak guard (#718) as the template new SDK integrations are expected to carry. OIDC access/refresh tokens stay in ADR 037. - **Server-originated me-ops.** SSR session validation (`auth()`, the framework middlewares) is public-plane software acting for the human: publishable key for attribution + the user's session cookie as principal — never the project secret, preserving the zero-platform-secrets endgame for SSR. Origin-allowlist enforcement is scoped to credential-*establishing* operations (flow ops, public-plane handoff exchange, where it also derives the WebAuthn RP ID); cookie-principal reads authenticate by the cookie — a strictly stronger credential than an attacker-settable `Origin` — so server-originated calls with no browser-attested `Origin` pass. - **SSR session validation happens at the render layer.** The `x-nextgen-auth-token` tunnel is named for what it was — an unnamed intra-app credential channel, spoofable wherever the middleware didn't run. #718 closed the spoof by re-verifying the header; this amendment removes the channel: `auth()` reads the session cookie (and `Authorization` in Route Handlers) directly and validates once, works on every route, and the `matcher` constrains only redirects — aligning server reads with `getSession()`. Middleware keeps two jobs: the credential-free proxy and full-validation redirect gating on `protectedRoutes` only. The HMAC-attestation alternative is explicitly foreclosed: it would need a durable shared secret in exactly the layer this ADR empties of secrets. The amendment also records specced-vs-wired: #717's `getSession()` is the model's client half (shipped), #718's verification core and client-safe boundary are the server half (shipped behind the old transport); the remaining work is the transport swap, tunnel deletion, publishable-key attachment once the key exists, and the matcher-prose sweep. Neither PR is obsoleted — the delete is ~30 lines of transport plus prose. ## Validation Docs-only. PR title validated with `scripts/check-pr-title.mjs`. Table style matches the ADR's existing tables. ## Release notes / changeset No changeset required — no shipped behavior changed. ## Notes - One judgment call to sanity-check in review: middleware **retains full validation on `protectedRoutes`** (a structural-only check would render signed-out content instead of redirecting), accepting bounded double-validation there. The alternative — moving redirect gating to the render layer too — changes only that bullet. - The origin carve-out implies verifier work when publishable keys land: origin checks become per-operation-class, not blanket per-credential. - Follow-up code PR (cookie-reading `auth()` in sdk-next, tunnel deletion, prose sweep) is gated on this review; sdk-nuxt already validates every request and only needs publishable-key wiring.
Summary
The 2026-08-02 Wicklore rerun (finding N3) showed that after embedding the scaffolded login/session widgets in a real app, the app's own chrome has no supported way to know session state — the header keeps rendering "Sign in"/"Join" beside a live session. The widgets read
GET /sessions/meinternally, but nothing documented how the host page gets the same answer.Root cause is structural on Next: the scaffolded request boundary's
matchercovers only/__nextgen/:path*and/profile/:path*, so on any other page the middleware never runs, no auth header is tunnelled, and server-sideauth()reports signed-out. Nuxt doesn't share the gap (its scaffolded auth plugin seedsuseAuth()on every render) — its gap was purely documentation.This PR ships the smallest honest surface:
@zitadel/sdk-next/session— new dependency-light entry exportinggetSession(): a client-side fetch of the same-origin{proxyPath}/sessions/me, the exact read<zitadel-session>performs. Works on any page (only the proxy path must be matched, which the scaffold always does), needs noconfigureZitadel(), and returns the client-safeClientAuthResult— no token.401/404and anonymous sessions map to signed-out; other failures throw rather than silently rendering signed-out; calling it server-side throws with a pointer toauth(). It's a dedicated subpath because the root barrel pullsnext/headersand./clientpulls Lit — both unsafe to import from an SSR'd header component.ClientSession/ClientAuthState/ClientAuthResultlifted from sdk-nuxt into@zitadel/sdk-coreas the single source; sdk-nuxt re-exports unchanged, souseAuth()(Nuxt) andgetSession()(Next) return the identical shape.AGENTS.mdmanaged section — Next namesgetSession()(plusauth()with its matcher precondition), Nuxt namesuseAuth(), SPA frameworks get the raw/__nextgen/sessions/meread (no claimed helper that doesn't exist there). Matching one-line comments emitted in the Next and Nuxt profile-page templates, plus a README section with a conditional-header example.Deliberately not in scope: no state-management layer, no new widget events (
zitadel-signout/zitadel-flow-completealready exist, and the scaffolded posture navigates on both transitions, so read-on-load suffices), noNextgenProvider-based pattern (see Notes).Validation
sdk-next:test103/103 (8 newgetSession()tests: identity mapping, null-attribute handling, anonymous session, 401/404 vs throw semantics, proxy-path resolution order, server-side guard)cli:test942/942 (new guidance test pinning the per-framework branching, incl. that non-Next frameworks never reference@zitadel/sdk-next/session)sdk-nuxt:test, plus build / typecheck / lint green across sdk-core, sdk-next, sdk-nuxt, cli;demo-nuxt:typecheckgreen (consumes the re-exported types through the built dist)dist/session.jssmoke-tested by direct import (function resolves; server-side guard fires)changeset statusverifieddemo-next:typecheckfails in this fresh worktree, stash-verified identical without these changes (pre-existing JSX-augmentation issue)Release notes / changeset
.changeset/session-state-for-app-chrome.md—@zitadel/sdk-nextminor (new./sessionentry),@zitadel/sdk-coreminor (new exported client-safe types),@zitadel/climinor (guidance + emitted comments),@zitadel/sdk-nuxtpatch (type re-export, no behavior change).Notes
auth()on matcher-uncovered routes trusts a client-forgeable unverifiedx-nextgen-auth-tokenheader, and the README'sNextgenProviderpattern serializes the raw session token into the RSC payload (sdk-nuxt strips it for exactly this reason). Both are behavior changes to existing surface with a design decision in theauth()fix; this PR only narrows documentedauth()usage to the safe (matcher-covered) region. The follow-up builds on theClientAuthResulttypes this PR moves to sdk-core.