@zitadel/sdk-next 1.0.0-alpha.25 → 1.0.0-alpha.28

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
@@ -5,9 +5,19 @@ Next.js middleware and helpers for Nextgen Auth.
5
5
  ## Installation
6
6
 
7
7
  ```bash
8
- pnpm add @zitadel/sdk-next
8
+ pnpm add @zitadel/sdk-next@alpha
9
9
  ```
10
10
 
11
+ Requires Next.js 15.2.3 or later. Earlier 15.x releases skip middleware for a
12
+ request that sends an `x-middleware-subrequest` header (CVE-2025-29927), which
13
+ on self-hosted deployments would let any request past `protectedRoutes`.
14
+
15
+ ## Requirements
16
+
17
+ Peer dependencies: `next >=15.2.3`, `react >=18` and `react-dom >=18`.
18
+
19
+ TypeScript ≥ 5.0, the floor shared by every SDK; see the [compatibility notes](https://github.com/zitadel/nextgen/blob/main/packages/AGENTS.md).
20
+
11
21
  ## Entry points
12
22
 
13
23
  | Import | Runs in | Provides |
@@ -52,20 +62,33 @@ The middleware runs on every matched route and does three things in one pass:
52
62
 
53
63
  1. **Proxies** `/__nextgen/*` requests to the auth backend
54
64
  2. **Verifies** the session JWT via JWKS using the Web Crypto API
55
- 3. **Redirects** unauthenticated requests to `loginPath` for protected routes
65
+ 3. **Redirects** unauthenticated requests to `loginPath` for protected routes, with the requested path and query string in `?next=`, where `<zitadel-login>` returns after sign-in
56
66
 
57
67
  ### 2. Reading auth in a Server Component
58
68
 
59
- ```ts
69
+ ```tsx
60
70
  import { auth } from "@zitadel/sdk-next/server";
71
+ import { Suspense } from "react";
72
+
73
+ export default function Page() {
74
+ return (
75
+ <Suspense fallback={<p>Loading…</p>}>
76
+ <Greeting />
77
+ </Suspense>
78
+ );
79
+ }
61
80
 
62
- export default async function Page() {
81
+ async function Greeting() {
63
82
  const session = await auth();
64
83
  if (!session.isAuthenticated) return <p>Not signed in</p>;
65
84
  return <p>Hello {session.session.display ?? session.session.identifier}</p>;
66
85
  }
67
86
  ```
68
87
 
88
+ `auth()` reads the request, so whatever awaits it renders inside
89
+ `<Suspense>`: with Cache Components on (`cacheComponents`, which
90
+ `create-next-app` enables), Next.js refuses request data outside it.
91
+
69
92
  `auth()` reads the token the middleware tunnelled into the request headers
70
93
  and **verifies it before trusting it** — JWTs cryptographically via JWKS,
71
94
  opaque tokens against the backend's `GET /sessions/me` (which also supplies
@@ -91,17 +114,24 @@ read the state with `useAuth()` anywhere below it:
91
114
 
92
115
  ```tsx
93
116
  import { auth, NextgenProvider } from '@zitadel/sdk-next/server';
117
+ import { Suspense } from 'react';
94
118
 
95
- export default async function RootLayout({ children }) {
96
- const session = await auth();
119
+ export default function RootLayout({ children }) {
97
120
  return (
98
121
  <html>
99
122
  <body>
100
- <NextgenProvider session={session}>{children}</NextgenProvider>
123
+ <Suspense>
124
+ <AuthProvider>{children}</AuthProvider>
125
+ </Suspense>
101
126
  </body>
102
127
  </html>
103
128
  );
104
129
  }
130
+
131
+ async function AuthProvider({ children }) {
132
+ const session = await auth();
133
+ return <NextgenProvider session={session}>{children}</NextgenProvider>;
134
+ }
105
135
  ```
106
136
 
107
137
  `NextgenProvider` converts the `auth()` result to the client-safe shape
@@ -144,8 +174,10 @@ scaffolded matcher covers just the proxy path and the protected routes — so a
144
174
  header on a public page would always look signed out. For your app's own chrome
145
175
  (header navigation, account menus), read the session client-side with
146
176
  `getSession()` from `@zitadel/sdk-next/session`. It fetches the same-origin
147
- `{proxyPath}/sessions/me` — the same read the `<zitadel-session>` card performs —
148
- so it works on every page and the answer is the server's:
177
+ `{proxyPath}/sessions/me?project_id=…` — the same read the `<zitadel-session>`
178
+ card performs — so it works on every page and the answer is the server's. The
179
+ project is the `projectId` option, else the project of `configureZitadel()`,
180
+ else `NEXT_PUBLIC_ZITADEL_PROJECT_ID`; with none, it reads as signed out:
149
181
 
150
182
  ```tsx
151
183
  'use client';
@@ -157,7 +189,7 @@ export function HeaderNav() {
157
189
  const [auth, setAuth] = useState<ClientAuthResult>();
158
190
  const [error, setError] = useState<Error>();
159
191
  useEffect(() => {
160
- getSession().then(setAuth, setError);
192
+ getSession({ projectId: process.env.NEXT_PUBLIC_ZITADEL_PROJECT_ID }).then(setAuth, setError);
161
193
  }, []);
162
194
  if (error) return <span role="alert">Session unavailable</span>;
163
195
  if (!auth) return null;
@@ -174,8 +206,9 @@ A rejected `getSession()` means the state is *unknown* (broken proxy, network,
174
206
 
175
207
  A `200` with a non-empty `user_id` resolves to
176
208
  `{ isAuthenticated: true, session: { userId, email, name } }` (client-safe —
177
- no token); the canonical `401/auth.unauthorized`,
178
- `404/sess.not_found`, and anonymous sessions resolve to signed out. The request
209
+ no token); the canonical `401/auth.unauthorized`, `403/sess.project_mismatch`
210
+ (a session of another project on the same host), `404/sess.not_found`, and
211
+ anonymous sessions resolve to signed out. The request
179
212
  and response are both marked no-store. Any other response — including malformed
180
213
  JSON or a framework's HTML 404 page from a misrouted proxy — throws so a broken
181
214
  proxy doesn't silently render as signed out. Sign-in and sign-out navigate
@@ -189,11 +222,20 @@ The `<zitadel-login>` web component (from `@zitadel/components`) must be rendere
189
222
 
190
223
  ```tsx
191
224
  // app/login/page.tsx (server)
192
- import { auth } from '@zitadel/sdk-next';
225
+ import { auth } from '@zitadel/sdk-next/server';
193
226
  import { redirect } from 'next/navigation';
227
+ import { Suspense } from 'react';
194
228
  import { LoginWidget } from './widget';
195
229
 
196
- export default async function LoginPage() {
230
+ export default function LoginPage() {
231
+ return (
232
+ <Suspense>
233
+ <SignedOutOnly />
234
+ </Suspense>
235
+ );
236
+ }
237
+
238
+ async function SignedOutOnly() {
197
239
  const session = await auth();
198
240
  if (session.isAuthenticated) redirect('/admin');
199
241
  return <LoginWidget />;
@@ -232,12 +274,39 @@ There is no `api-base` attribute — the element reads the global handle from
232
274
  `configureZitadel()` (or you can assign the returned handle to the element's
233
275
  `project` property).
234
276
 
277
+ ## Widget events
278
+
279
+ The widgets are plain custom elements, so each event is a DOM `CustomEvent` that carries its payload in `detail`. The events and payloads are the same ones every SPA SDK exposes as callbacks.
280
+
281
+ | Widget event | Fires when | Detail | Widgets |
282
+ | --- | --- | --- | --- |
283
+ | `zitadel-flow-step` | Login shows a step, the first one included. | `{ step }` | Login |
284
+ | `zitadel-flow-input` | The visitor edits a field. | `{ name, value }` | Login |
285
+ | `zitadel-flow-complete` | The flow reaches a terminal step. | `{ behavior, redirect_uri?, handoff_token?, handoff_token_expires_at? }` | Login |
286
+ | `zitadel-flow-redirect` | The widget hands the browser to an identity provider; nobody is signed in yet. | `{ redirect_url, step }` | Login |
287
+ | `zitadel-flow-error` | The flow reports an error. | `{ message }` | Login |
288
+ | `zitadel-signout` | The user signs out. | `{ display, identifier }` | Logout, Session |
289
+
290
+ ```tsx
291
+ const ref = useRef<HTMLElementTagNameMap['zitadel-login']>(null);
292
+
293
+ useEffect(() => {
294
+ const login = ref.current;
295
+ const onComplete = (event: Event) => track((event as CustomEvent).detail);
296
+ login?.addEventListener('zitadel-flow-complete', onComplete);
297
+ return () => login?.removeEventListener('zitadel-flow-complete', onComplete);
298
+ }, []);
299
+
300
+ return <zitadel-login ref={ref} post-sign-in-url="/admin" />;
301
+ ```
302
+
235
303
  ## Middleware options
236
304
 
237
305
  | Option | Type | Default | Description |
238
306
  | ------------------- | -------------------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------- |
239
- | `url` | `string` | `ZITADEL_URL` env | Full URL of the Zitadel auth backend |
240
- | `proxyPath` | `string` | `"/__nextgen"` | Path prefix proxied to the auth backend. Sign-in with an external provider currently requires the default: the provider returns to `/__nextgen/idp/callback` |
307
+ | `url` | `string` | `ZITADEL_URL` env | Full URL of the Zitadel auth backend. A trailing slash is ignored. |
308
+ | `projectId` | `string` | `ZITADEL_PROJECT_ID`, else `NEXT_PUBLIC_ZITADEL_PROJECT_ID` | The project this app signs its users in to. A session of any other project on the server reads as signed out and keeps its cookie; with no project, no session counts. Pass the same value to `auth()` |
309
+ | `proxyPath` | `string` | `"/__nextgen"` | Path prefix proxied to the auth backend. Sign-in with an external provider currently requires the default: the provider returns to `/__nextgen/idp/{slug}/callback` |
241
310
  | `protectedRoutes` | `string[]` | `[]` | Paths requiring a valid session. Trailing `*` matches sub-paths |
242
311
  | `ignoredRoutes` | `string[]` | `[]` | Paths skipped entirely — no JWT check, no tunnelling. Useful for webhooks or health checks. Trailing `*` matches sub-paths |
243
312
  | `loginPath` | `string` | `"/login"` | Where to redirect unauthenticated users |
@@ -256,5 +325,5 @@ The verification pipeline is shared across SDKs and documented once in
256
325
  [`@zitadel/sdk-core`](https://github.com/zitadel/nextgen/tree/main/packages/sdk-core#how-jwt-verification-works).
257
326
  On top of that pipeline, the Next.js middleware adds:
258
327
 
259
- 1. The `x-nextgen-auth-token` header is stripped from all proxied requests to prevent internal state leakage
328
+ 1. A request that carries an `x-nextgen-auth-token` header is refused with a 400 on every route, including ignored routes and the proxy path: the header is an internal channel that only the middleware writes, so a client never sends it
260
329
  2. `auth()` re-applies the same verification to the tunnelled token in the server runtime — the header alone is never treated as proof of a session
package/dist/auth.d.ts CHANGED
@@ -9,7 +9,7 @@ import type { AuthResult, NextgenMiddlewareOptions } from "./types.js";
9
9
  * a token the middleware accepted can fail re-verification in `auth()` and
10
10
  * render as signed out.
11
11
  */
12
- export type AuthOptions = Pick<NextgenMiddlewareOptions, "url" | "allowedAlgorithms" | "clockSkewMs" | "audience" | "allowedTokenTypes" | "jwksTimeoutMs" | "opaqueTokenTimeoutMs">;
12
+ export type AuthOptions = Pick<NextgenMiddlewareOptions, "url" | "projectId" | "allowedAlgorithms" | "clockSkewMs" | "audience" | "allowedTokenTypes" | "jwksTimeoutMs" | "opaqueTokenTimeoutMs">;
13
13
  /**
14
14
  * Reads the auth state in a React Server Component or Next.js Route Handler.
15
15
  *
@@ -27,10 +27,22 @@ export type AuthOptions = Pick<NextgenMiddlewareOptions, "url" | "allowedAlgorit
27
27
  * `cache()`.
28
28
  *
29
29
  * The header alone is never proof of anything: on routes the middleware
30
- * `matcher` does not cover, the middleware cannot neutralise a forged
30
+ * `matcher` does not cover, the middleware cannot refuse a forged
31
31
  * client-supplied header, so `auth()` re-verifies every value it reads.
32
32
  * A header value that fails verification is treated as signed out.
33
33
  *
34
+ * Either way a session counts only in the app's own project: `projectId`,
35
+ * else `ZITADEL_PROJECT_ID`, else `NEXT_PUBLIC_ZITADEL_PROJECT_ID`, else the
36
+ * project of `configureZitadel()` when it has run in this server runtime. A
37
+ * JWT must name it in `aud`; an opaque token must be a session of that
38
+ * project. With no project, nobody is signed in. A middleware built with
39
+ * `createProxy(config)` holds sessions to `config.projectId`: pass the same
40
+ * project here unless it comes from one of those variables.
41
+ *
42
+ * A backend that answers with an unexpected status, or not at all, reads as
43
+ * signed out and is logged, so a broken proxy or an unreachable backend is
44
+ * distinguishable from a missing session.
45
+ *
34
46
  * **Matcher precondition (read this):** the middleware only tunnels the
35
47
  * session token on routes covered by its `matcher`. On uncovered routes the
36
48
  * cookie never reaches `auth()`, so `auth()` reports signed out even when a
@@ -1 +1 @@
1
- {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"AAAA,OAAO,aAAa,CAAC;AAOrB,OAAO,KAAK,EAAE,UAAU,EAAiB,wBAAwB,EAAE,MAAM,YAAY,CAAC;AAItF;;;;;;;;GAQG;AACH,MAAM,MAAM,WAAW,GAAG,IAAI,CAC5B,wBAAwB,EACtB,KAAK,GACL,mBAAmB,GACnB,aAAa,GACb,UAAU,GACV,mBAAmB,GACnB,eAAe,GACf,sBAAsB,CACzB,CAAC;AA+DF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqDG;AACH,wBAAsB,IAAI,CAAC,OAAO,GAAE,WAAgB,GAAG,OAAO,CAAC,UAAU,CAAC,CA8DzE"}
1
+ {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"AAAA,OAAO,aAAa,CAAC;AAQrB,OAAO,KAAK,EAAE,UAAU,EAAE,wBAAwB,EAAE,MAAM,YAAY,CAAC;AAIvE;;;;;;;;GAQG;AACH,MAAM,MAAM,WAAW,GAAG,IAAI,CAC5B,wBAAwB,EACtB,KAAK,GACL,WAAW,GACX,mBAAmB,GACnB,aAAa,GACb,UAAU,GACV,mBAAmB,GACnB,eAAe,GACf,sBAAsB,CACzB,CAAC;AAgBF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiEG;AACH,wBAAsB,IAAI,CAAC,OAAO,GAAE,WAAgB,GAAG,OAAO,CAAC,UAAU,CAAC,CAsFzE"}
package/dist/auth.js CHANGED
@@ -1,57 +1,20 @@
1
1
  import "server-only";
2
+ import { getZitadelConfig } from "@zitadel/api/config";
3
+ import { checkOpaqueSessionToken, jwtNamesProject } from "@zitadel/sdk-core/middleware";
4
+ import { joinURL } from "ufo";
2
5
  import { headers } from "next/headers";
3
6
  import { cache } from "react";
4
7
  import { isJwtShaped, verifyJwt } from "./lib/jwt.js";
5
8
  /**
6
- * Validates an opaque (non-JWT) session token against the backend's
7
- * `GET /sessions/me` and returns the client-safe identity, or `null` when the
8
- * backend does not confirm a signed-in user.
9
+ * Checks an opaque (non-JWT) session token against the backend's
10
+ * `GET /sessions/me`, held to `projectId` (see `checkOpaqueSessionToken` in
11
+ * `@zitadel/sdk-core`, which the middleware uses too).
9
12
  *
10
13
  * Wrapped in React's `cache()` so that many components calling `auth()` in
11
14
  * the same render pass share a single backend round-trip. Arguments are
12
15
  * primitives on purpose — `cache()` memoises by argument identity.
13
- *
14
- * Fail-closed: network errors, timeouts, and unexpected statuses all resolve
15
- * to `null` (signed out). Unexpected failures are logged so a broken proxy or
16
- * unreachable backend is distinguishable from a genuinely missing session.
17
16
  */
18
- const validateOpaqueSession = cache(async (token, issuerUrl, timeoutMs) => {
19
- try {
20
- const res = await fetch(`${issuerUrl}/sessions/me`, {
21
- method: "GET",
22
- headers: { accept: "application/json", cookie: `__nextgen_session=${token}` },
23
- signal: AbortSignal.timeout(timeoutMs),
24
- });
25
- // 401 = no/invalid session token, 404 = session gone (revoked/expired):
26
- // both are the server's definitive "not signed in".
27
- if (res.status === 401 || res.status === 404) {
28
- return null;
29
- }
30
- if (!res.ok) {
31
- console.warn(`[nextgen] auth(): session validation failed with HTTP ${res.status} from ` +
32
- `${issuerUrl}/sessions/me — treating as signed out. If this persists, the ` +
33
- `backend URL is likely misconfigured (ZITADEL_URL or the \`url\` option).`);
34
- return null;
35
- }
36
- const session = (await res.json());
37
- // An anonymous session (no verified user factor yet) has no user_id —
38
- // for server-rendered UI that is "not signed in".
39
- if (!session.user_id) {
40
- return null;
41
- }
42
- return {
43
- userId: session.user_id,
44
- identifier: session.user?.identifier ?? null,
45
- identifierProperty: session.user?.identifier_property ?? null,
46
- display: session.user?.display ?? null,
47
- };
48
- }
49
- catch {
50
- console.warn(`[nextgen] auth(): could not reach ${issuerUrl}/sessions/me to validate the ` +
51
- `session token — treating as signed out.`);
52
- return null;
53
- }
54
- });
17
+ const checkOpaqueSession = cache((token, issuerUrl, timeoutMs, projectId) => checkOpaqueSessionToken(token, issuerUrl, timeoutMs, projectId));
55
18
  /**
56
19
  * Reads the auth state in a React Server Component or Next.js Route Handler.
57
20
  *
@@ -69,10 +32,22 @@ const validateOpaqueSession = cache(async (token, issuerUrl, timeoutMs) => {
69
32
  * `cache()`.
70
33
  *
71
34
  * The header alone is never proof of anything: on routes the middleware
72
- * `matcher` does not cover, the middleware cannot neutralise a forged
35
+ * `matcher` does not cover, the middleware cannot refuse a forged
73
36
  * client-supplied header, so `auth()` re-verifies every value it reads.
74
37
  * A header value that fails verification is treated as signed out.
75
38
  *
39
+ * Either way a session counts only in the app's own project: `projectId`,
40
+ * else `ZITADEL_PROJECT_ID`, else `NEXT_PUBLIC_ZITADEL_PROJECT_ID`, else the
41
+ * project of `configureZitadel()` when it has run in this server runtime. A
42
+ * JWT must name it in `aud`; an opaque token must be a session of that
43
+ * project. With no project, nobody is signed in. A middleware built with
44
+ * `createProxy(config)` holds sessions to `config.projectId`: pass the same
45
+ * project here unless it comes from one of those variables.
46
+ *
47
+ * A backend that answers with an unexpected status, or not at all, reads as
48
+ * signed out and is logged, so a broken proxy or an unreachable backend is
49
+ * distinguishable from a missing session.
50
+ *
76
51
  * **Matcher precondition (read this):** the middleware only tunnels the
77
52
  * session token on routes covered by its `matcher`. On uncovered routes the
78
53
  * cookie never reaches `auth()`, so `auth()` reports signed out even when a
@@ -112,7 +87,13 @@ export async function auth(options = {}) {
112
87
  if (!token) {
113
88
  return { isAuthenticated: false, session: null };
114
89
  }
115
- const { url = process.env.ZITADEL_URL ?? "http://localhost:8080", allowedAlgorithms = ["RS256", "ES256"], clockSkewMs = 5000, audience, allowedTokenTypes = ["JWT", "at+JWT"], jwksTimeoutMs, opaqueTokenTimeoutMs = 5000, } = options;
90
+ const { url = process.env.ZITADEL_URL ?? "http://localhost:8080", projectId = process.env.ZITADEL_PROJECT_ID ||
91
+ process.env.NEXT_PUBLIC_ZITADEL_PROJECT_ID ||
92
+ getZitadelConfig()?.projectId, allowedAlgorithms = ["RS256", "ES256"], clockSkewMs = 5000, audience, allowedTokenTypes = ["JWT", "at+JWT"], jwksTimeoutMs, opaqueTokenTimeoutMs = 5000, } = options;
93
+ if (!projectId) {
94
+ warnMissingProject();
95
+ return { isAuthenticated: false, session: null };
96
+ }
116
97
  if (isJwtShaped(token)) {
117
98
  const payload = await verifyJwt(token, {
118
99
  issuerUrl: url,
@@ -122,7 +103,7 @@ export async function auth(options = {}) {
122
103
  allowedTokenTypes,
123
104
  jwksTimeoutMs,
124
105
  });
125
- if (payload?.sub) {
106
+ if (payload?.sub && jwtNamesProject(payload, projectId)) {
126
107
  return {
127
108
  isAuthenticated: true,
128
109
  session: {
@@ -134,6 +115,10 @@ export async function auth(options = {}) {
134
115
  },
135
116
  };
136
117
  }
118
+ if (payload?.sub) {
119
+ // Verified, but issued for another project: not a user of this one.
120
+ return { isAuthenticated: false, session: null };
121
+ }
137
122
  // A middleware-tunnelled JWT always verifies (the middleware applied the
138
123
  // same checks moments earlier). Reaching this branch means the header was
139
124
  // NOT vetted by the middleware — a route outside the `matcher` received a
@@ -143,12 +128,35 @@ export async function auth(options = {}) {
143
128
  console.warn("[nextgen] auth(): rejected an x-nextgen-auth-token header that failed JWT " +
144
129
  "verification. If this route is not covered by the middleware matcher, this was " +
145
130
  "a forged client header; otherwise check that auth() and the middleware use the " +
146
- `same verification options and that the JWKS endpoint at ${url}/auth/keys is reachable.`);
131
+ `same verification options and that the JWKS endpoint at ${joinURL(url, "/auth/keys")} is reachable.`);
147
132
  return { isAuthenticated: false, session: null };
148
133
  }
149
- const session = await validateOpaqueSession(token, url, opaqueTokenTimeoutMs);
150
- if (session) {
151
- return { isAuthenticated: true, session: { ...session, token } };
134
+ // A refused session, an anonymous one and one of another project read as
135
+ // signed out here. So does a check without a verdict, which
136
+ // `checkOpaqueSessionToken` logs with its cause.
137
+ const check = await checkOpaqueSession(token, url, opaqueTokenTimeoutMs, projectId);
138
+ if (check.verdict === "live" && check.session.userId) {
139
+ return {
140
+ isAuthenticated: true,
141
+ session: {
142
+ userId: check.session.userId,
143
+ identifier: check.session.identifier,
144
+ identifierProperty: check.session.identifierProperty,
145
+ display: check.session.display,
146
+ token,
147
+ },
148
+ };
152
149
  }
153
150
  return { isAuthenticated: false, session: null };
154
151
  }
152
+ let warnedMissingProject = false;
153
+ /** Says once per process why `auth()` signs no one in: no project is configured. */
154
+ function warnMissingProject() {
155
+ if (warnedMissingProject) {
156
+ return;
157
+ }
158
+ warnedMissingProject = true;
159
+ console.warn("[nextgen] auth(): no project id is configured, so no session counts as signed in. " +
160
+ "Set ZITADEL_PROJECT_ID, or pass the middleware's `projectId` to auth() (with " +
161
+ "createProxy(config), that is config.projectId).");
162
+ }
package/dist/client.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Re-exports the `<zitadel-login>` and `<zitadel-logout>` Lit web components
3
- * for use in Next.js apps.
2
+ * Registers the `<zitadel-login>`, `<zitadel-logout>` and `<zitadel-session>`
3
+ * Lit web components for use in Next.js apps.
4
4
  *
5
5
  * Import this inside a `"use client"` boundary (e.g. a dynamic import
6
6
  * with `{ ssr: false }`) to register the custom elements with the
@@ -10,6 +10,9 @@
10
10
  * await import("@zitadel/sdk-next/client");
11
11
  * ```
12
12
  *
13
+ * On an edge runtime the components package resolves to an entry without the
14
+ * elements, so this entry still loads there.
15
+ *
13
16
  * SDK configuration is done via `configureZitadel()`, re-exported here so
14
17
  * a consuming app that only declares `@zitadel/sdk-next` as a direct
15
18
  * dependency can configure the SDK without reaching into
@@ -17,7 +20,8 @@
17
20
  * resolve). Call it inside the same `"use client"` boundary before the
18
21
  * components mount.
19
22
  */
20
- export { ZitadelLogin, ZitadelLogout, ZitadelSession, businessLocales } from "@zitadel/components";
23
+ import "@zitadel/components";
24
+ export { businessLocales } from "@zitadel/components";
21
25
  export { configureZitadel, getApi } from "@zitadel/api/config";
22
26
  export type { ZitadelConfig, ZitadelProject } from "@zitadel/api/config";
23
27
  //# sourceMappingURL=client.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AACH,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AACnG,OAAO,EAAE,gBAAgB,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAC;AAC/D,YAAY,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAGH,OAAO,qBAAqB,CAAC;AAI7B,OAAO,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AACtD,OAAO,EAAE,gBAAgB,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAC;AAC/D,YAAY,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC"}
package/dist/client.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Re-exports the `<zitadel-login>` and `<zitadel-logout>` Lit web components
3
- * for use in Next.js apps.
2
+ * Registers the `<zitadel-login>`, `<zitadel-logout>` and `<zitadel-session>`
3
+ * Lit web components for use in Next.js apps.
4
4
  *
5
5
  * Import this inside a `"use client"` boundary (e.g. a dynamic import
6
6
  * with `{ ssr: false }`) to register the custom elements with the
@@ -10,6 +10,9 @@
10
10
  * await import("@zitadel/sdk-next/client");
11
11
  * ```
12
12
  *
13
+ * On an edge runtime the components package resolves to an entry without the
14
+ * elements, so this entry still loads there.
15
+ *
13
16
  * SDK configuration is done via `configureZitadel()`, re-exported here so
14
17
  * a consuming app that only declares `@zitadel/sdk-next` as a direct
15
18
  * dependency can configure the SDK without reaching into
@@ -17,5 +20,10 @@
17
20
  * resolve). Call it inside the same `"use client"` boundary before the
18
21
  * components mount.
19
22
  */
20
- export { ZitadelLogin, ZitadelLogout, ZitadelSession, businessLocales } from "@zitadel/components";
23
+ // Registers <zitadel-login>, <zitadel-logout> and <zitadel-session> in the browser.
24
+ // The edge build of the components package has no elements, so this does nothing there.
25
+ import "@zitadel/components";
26
+ // Re-exported so scaffolded apps can wire the business copy overlay without a
27
+ // direct @zitadel/components dependency (strict package managers reject those).
28
+ export { businessLocales } from "@zitadel/components";
21
29
  export { configureZitadel, getApi } from "@zitadel/api/config";
@@ -20,6 +20,9 @@ import { NextResponse } from "next/server";
20
20
  * });
21
21
  * }
22
22
  *
23
+ * A session counts only in the app's own project: `projectId`, else
24
+ * `ZITADEL_PROJECT_ID`, else `NEXT_PUBLIC_ZITADEL_PROJECT_ID`.
25
+ *
23
26
  * export const config = {
24
27
  * matcher: ["/__nextgen/:path*", "/profile/:path*"],
25
28
  * };
@@ -54,7 +57,7 @@ export type ProxyHandler = (req: NextRequest) => Promise<NextResponse | Response
54
57
  * import { createProxy } from "@zitadel/sdk-next/middleware";
55
58
  *
56
59
  * const zitadel = configureZitadel({
57
- * projectId: "demo",
60
+ * projectId: process.env.NEXT_PUBLIC_ZITADEL_PROJECT_ID ?? "",
58
61
  * url: process.env.ZITADEL_URL,
59
62
  * });
60
63
  *
@@ -71,6 +74,12 @@ export type ProxyHandler = (req: NextRequest) => Promise<NextResponse | Response
71
74
  * export const middleware = proxy;
72
75
  * ```
73
76
  *
77
+ * Sessions are held to the handle's project unless `options.projectId` names
78
+ * another. `auth()` runs in the server runtime, where the middleware's handle
79
+ * may not be configured: it reads `ZITADEL_PROJECT_ID` /
80
+ * `NEXT_PUBLIC_ZITADEL_PROJECT_ID`, else a handle configured there. When the
81
+ * project lives only in this handle, pass it as `auth({ projectId })` too.
82
+ *
74
83
  * @param config - The SDK handle from `configureZitadel()`.
75
84
  * @param options - Route protection and JWT options.
76
85
  * @returns A middleware handler function.
@@ -1 +1 @@
1
- {"version":3,"file":"middleware.d.ts","sourceRoot":"","sources":["../src/middleware.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAC1D,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,8BAA8B,CAAC;AAC7E,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAQ/C,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAmC3C;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAsB,iBAAiB,CACrC,GAAG,EAAE,WAAW,EAChB,OAAO,GAAE,wBAA6B,GACrC,OAAO,CAAC,YAAY,GAAG,QAAQ,CAAC,CAqDlC;AAuPD;;;;;;;GAOG;AACH,MAAM,MAAM,YAAY,GAAG,IAAI,CAAC,wBAAwB,EAAE,WAAW,GAAG,KAAK,CAAC,CAAC;AAE/E;;GAEG;AACH,MAAM,MAAM,YAAY,GAAG,CAAC,GAAG,EAAE,WAAW,KAAK,OAAO,CAAC,YAAY,GAAG,QAAQ,CAAC,CAAC;AAElF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,wBAAgB,WAAW,CAAC,MAAM,EAAE,cAAc,EAAE,OAAO,GAAE,YAAiB,GAAG,YAAY,CAO5F"}
1
+ {"version":3,"file":"middleware.d.ts","sourceRoot":"","sources":["../src/middleware.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAC1D,OAAO,KAAK,EAAE,wBAAwB,EAAyB,MAAM,8BAA8B,CAAC;AAEpG,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAa/C,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAmC3C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,wBAAsB,iBAAiB,CACrC,GAAG,EAAE,WAAW,EAChB,OAAO,GAAE,wBAA6B,GACrC,OAAO,CAAC,YAAY,GAAG,QAAQ,CAAC,CAyDlC;AAuMD;;;;;;;GAOG;AACH,MAAM,MAAM,YAAY,GAAG,IAAI,CAAC,wBAAwB,EAAE,WAAW,GAAG,KAAK,CAAC,CAAC;AAE/E;;GAEG;AACH,MAAM,MAAM,YAAY,GAAG,CAAC,GAAG,EAAE,WAAW,KAAK,OAAO,CAAC,YAAY,GAAG,QAAQ,CAAC,CAAC;AAElF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AACH,wBAAgB,WAAW,CAAC,MAAM,EAAE,cAAc,EAAE,OAAO,GAAE,YAAiB,GAAG,YAAY,CAQ5F"}
@@ -1,6 +1,6 @@
1
- import { HOP_BY_HOP, INTERNAL_HEADERS, filterResponseHeaders, matchesRoutes, } from "@zitadel/sdk-core/middleware";
1
+ import { joinURL } from "ufo";
2
+ import { HOP_BY_HOP, INTERNAL_HEADERS, filterResponseHeaders, loginRedirectUrl, matchesRoutes, proxySessionCookies, SESSION_COOKIE_CACHE_CONTROL, requiresProjectSecret, resolveSession, } from "@zitadel/sdk-core/middleware";
2
3
  import { NextResponse } from "next/server";
3
- import { verifyJwt, isJwtShaped } from "./lib/jwt.js";
4
4
  /**
5
5
  * Clones the incoming request headers, injects `extra` key/value pairs,
6
6
  * and registers them with the Next.js header-tunnelling mechanism so that
@@ -19,6 +19,8 @@ function tunnelHeaders(req, extra) {
19
19
  // scratch using only the names we inject ourselves.
20
20
  headers.delete("x-middleware-override-headers");
21
21
  const injectedNames = [];
22
+ // This is the only writer of x-nextgen-auth-token: it injects the verified
23
+ // value for server components, and a client-sent one never reaches here.
22
24
  for (const [name, value] of Object.entries(extra)) {
23
25
  headers.set(name, value);
24
26
  headers.set(`x-middleware-request-${name}`, value);
@@ -45,6 +47,9 @@ function tunnelHeaders(req, extra) {
45
47
  * });
46
48
  * }
47
49
  *
50
+ * A session counts only in the app's own project: `projectId`, else
51
+ * `ZITADEL_PROJECT_ID`, else `NEXT_PUBLIC_ZITADEL_PROJECT_ID`.
52
+ *
48
53
  * export const config = {
49
54
  * matcher: ["/__nextgen/:path*", "/profile/:path*"],
50
55
  * };
@@ -55,7 +60,7 @@ function tunnelHeaders(req, extra) {
55
60
  * @returns A `NextResponse` or `Response` to continue, redirect, or proxy.
56
61
  */
57
62
  export async function nextgenMiddleware(req, options = {}) {
58
- const { url = process.env.ZITADEL_URL ?? "http://localhost:8080", proxyPath = "/__nextgen", protectedRoutes = [], ignoredRoutes = [], loginPath = "/login", allowedAlgorithms = ["RS256", "ES256"], clockSkewMs = 5000, audience, allowedTokenTypes = ["JWT", "at+JWT"], jwksTimeoutMs, proxyTimeoutMs = 5000, opaqueTokenTimeoutMs = 5000, } = options;
63
+ const { url = process.env.ZITADEL_URL ?? "http://localhost:8080", projectId = process.env.ZITADEL_PROJECT_ID || process.env.NEXT_PUBLIC_ZITADEL_PROJECT_ID, proxyPath = "/__nextgen", protectedRoutes = [], ignoredRoutes = [], loginPath = "/login", allowedAlgorithms = ["RS256", "ES256"], clockSkewMs = 5000, audience, allowedTokenTypes = ["JWT", "at+JWT"], jwksTimeoutMs, proxyTimeoutMs = 5000, opaqueTokenTimeoutMs = 5000, } = options;
59
64
  // Guard against open-redirect: loginPath must be a relative path. An absolute
60
65
  // URL (e.g. "https://evil.com/phish") or a protocol-relative URL
61
66
  // (e.g. "//evil.com") would redirect the browser to an external host.
@@ -64,19 +69,21 @@ export async function nextgenMiddleware(req, options = {}) {
64
69
  `Received: "${loginPath}". Using an absolute or protocol-relative URL ` +
65
70
  `would allow open-redirect attacks.`);
66
71
  }
72
+ // Clients never send x-nextgen-auth-token: it is an internal channel. Refuse any
73
+ // request that carries it, on every route, before anything else runs.
74
+ if (req.headers.has("x-nextgen-auth-token")) {
75
+ return new Response("Bad Request", { status: 400 });
76
+ }
67
77
  const { pathname } = new URL(req.url);
68
78
  if (matchesRoutes(pathname, ignoredRoutes)) {
69
- // Neutralise any client-supplied x-nextgen-auth-token on ignored routes.
70
- // handleAuth is skipped for ignored routes, so we must strip the header
71
- // here to prevent a forged value from reaching server components.
72
- const headers = tunnelHeaders(req, { "x-nextgen-auth-token": "" });
73
- return NextResponse.next({ request: { headers } });
79
+ return NextResponse.next();
74
80
  }
75
81
  if (pathname === proxyPath || pathname.startsWith(`${proxyPath}/`)) {
76
- return proxyRequest(req, url, proxyPath, proxyTimeoutMs);
82
+ return proxyRequest(req, url, proxyPath, projectId, proxyTimeoutMs);
77
83
  }
78
84
  return handleAuth(req, {
79
85
  url,
86
+ projectId,
80
87
  protectedRoutes,
81
88
  loginPath,
82
89
  allowedAlgorithms,
@@ -88,30 +95,11 @@ export async function nextgenMiddleware(req, options = {}) {
88
95
  pathname,
89
96
  });
90
97
  }
91
- /**
92
- * Validates an opaque (non-JWT) session token by calling the backend's
93
- * `/sessions/me` endpoint. Returns `true` when the backend confirms the
94
- * session is live (HTTP 200), `false` otherwise.
95
- *
96
- * This is the fallback path for backends that issue encrypted opaque tokens
97
- * rather than self-contained JWTs.
98
- */
99
- async function validateOpaqueSessionToken(token, issuerUrl, timeoutMs) {
100
- try {
101
- const res = await fetch(`${issuerUrl}/sessions/me`, {
102
- method: "GET",
103
- headers: { cookie: `__nextgen_session=${token}` },
104
- signal: AbortSignal.timeout(timeoutMs),
105
- });
106
- return res.ok;
107
- }
108
- catch {
109
- return false;
110
- }
111
- }
112
98
  /**
113
99
  * Forwards a `/__nextgen/*` request to the upstream auth backend and streams
114
- * the response back verbatim, stripping hop-by-hop headers in both directions.
100
+ * the response back, stripping hop-by-hop headers in both directions. The
101
+ * session cookie travels under this project's name in the browser and the
102
+ * server's name upstream ({@link proxySessionCookies}).
115
103
  *
116
104
  * All non-hop-by-hop headers from the incoming request are forwarded, including
117
105
  * any `X-Forwarded-For` chain already set by an upstream CDN or load balancer.
@@ -123,12 +111,13 @@ async function validateOpaqueSessionToken(token, issuerUrl, timeoutMs) {
123
111
  * @param req - The incoming edge request.
124
112
  * @param authUrl - Base URL of the auth backend.
125
113
  * @param proxyPath - The path prefix being proxied (e.g. `"/__nextgen"`).
114
+ * @param projectId - The project this app signs its users in to.
126
115
  * @returns The proxied upstream `Response`.
127
116
  */
128
- async function proxyRequest(req, authUrl, proxyPath, proxyTimeoutMs) {
117
+ async function proxyRequest(req, authUrl, proxyPath, projectId, proxyTimeoutMs) {
129
118
  const url = new URL(req.url);
130
119
  const suffix = url.pathname.slice(proxyPath.length);
131
- const target = `${authUrl}${suffix}${url.search}`;
120
+ const target = `${joinURL(authUrl, suffix)}${url.search}`;
132
121
  const upstreamHeaders = new Headers();
133
122
  for (const [key, value] of req.headers.entries()) {
134
123
  const lower = key.toLowerCase();
@@ -140,7 +129,7 @@ async function proxyRequest(req, authUrl, proxyPath, proxyTimeoutMs) {
140
129
  // upstream auth server sees the full proxy path. We never skip this even when
141
130
  // a chain is already present — a CDN or load balancer may have set XFF before
142
131
  // the request reached this edge node, and our hop must still be recorded.
143
- const directIp = req.ip ?? req.headers.get("x-real-ip");
132
+ const directIp = req.ip;
144
133
  if (directIp) {
145
134
  const existingXff = upstreamHeaders.get("x-forwarded-for");
146
135
  upstreamHeaders.set("x-forwarded-for", existingXff ? `${existingXff}, ${directIp}` : directIp);
@@ -151,6 +140,21 @@ async function proxyRequest(req, authUrl, proxyPath, proxyTimeoutMs) {
151
140
  if (!upstreamHeaders.has("x-forwarded-proto")) {
152
141
  upstreamHeaders.set("x-forwarded-proto", url.protocol.replace(":", ""));
153
142
  }
143
+ const sessionCookies = proxySessionCookies({
144
+ method: req.method,
145
+ path: suffix,
146
+ search: url.search,
147
+ cookie: req.headers.get("cookie"),
148
+ requestUrl: url,
149
+ forwardedProto: req.headers.get("x-forwarded-proto"),
150
+ forwardedHost: req.headers.get("x-forwarded-host"),
151
+ }, projectId, { url: authUrl, timeoutMs: proxyTimeoutMs });
152
+ if (sessionCookies.upstreamCookie === null) {
153
+ upstreamHeaders.delete("cookie");
154
+ }
155
+ else {
156
+ upstreamHeaders.set("cookie", sessionCookies.upstreamCookie);
157
+ }
154
158
  const hasBody = !["GET", "HEAD"].includes(req.method);
155
159
  // ADR 036: the current browser flow needs the confidential project secret
156
160
  // only for the handoff exchange. Stamping it onto every proxied operation
@@ -177,80 +181,77 @@ async function proxyRequest(req, authUrl, proxyPath, proxyTimeoutMs) {
177
181
  // The app's own request URL: the origin a proxied redirect may name, and
178
182
  // the base a relative one resolves against.
179
183
  const responseHeaders = filterResponseHeaders(upstream.headers, req.nextUrl.toString());
180
- const setCookies = upstream.headers.getSetCookie?.() ?? [];
181
- for (const cookie of setCookies) {
184
+ const answer = await sessionCookies.toBrowser(upstream);
185
+ for (const cookie of answer.setCookies) {
182
186
  responseHeaders.append("set-cookie", cookie);
183
187
  }
184
- return new Response(upstream.body, {
188
+ if (answer.setCookies.length > 0) {
189
+ responseHeaders.set("cache-control", SESSION_COOKIE_CACHE_CONTROL);
190
+ }
191
+ return new Response(answer.body, {
185
192
  status: upstream.status,
186
193
  headers: responseHeaders,
187
194
  });
188
195
  }
189
- function requiresProjectSecret(method, pathname) {
190
- return method.toUpperCase() === "POST" && pathname === "/sessions/exchange";
191
- }
192
196
  /**
193
- * Verifies the session token and tunnels the result to server components via
194
- * a request header. Redirects to the login page when the token is absent or
195
- * invalid on a protected route.
197
+ * Judges the session and tunnels the token of a signed-in user to server
198
+ * components via a request header, where `auth()` verifies it again.
199
+ * Redirects to the login page when nobody is signed in on a protected route.
200
+ * Either way the response carries the session cookies the judgement asks for.
201
+ * When the backend cannot say whether the session is live, a protected route
202
+ * answers `503` instead.
196
203
  *
197
204
  * @param req - The incoming edge request.
198
205
  * @param opts - Auth handler options.
199
- * @returns A `NextResponse` to continue or redirect.
206
+ * @returns A `NextResponse` to continue, redirect, or answer `503`.
200
207
  */
201
208
  async function handleAuth(req, opts) {
202
- const { url, protectedRoutes, loginPath, allowedAlgorithms, clockSkewMs, audience, allowedTokenTypes, jwksTimeoutMs, opaqueTokenTimeoutMs, pathname, } = opts;
209
+ const { protectedRoutes, loginPath, pathname, ...verification } = opts;
203
210
  const authHeader = req.headers.get("authorization");
204
211
  const bearerToken = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
205
- const cookieToken = req.cookies.get("__nextgen_session")?.value ?? null;
206
- // Bearer token takes explicit precedence over the session cookie. API clients
207
- // (e.g. mobile apps, CLIs) use Authorization headers while browsers use
208
- // cookies; when both are present the caller clearly intended the Bearer token.
209
- const token = bearerToken ?? cookieToken;
210
- const payload = token
211
- ? await verifyJwt(token, {
212
- issuerUrl: url,
213
- allowedAlgorithms,
214
- clockSkewMs,
215
- audience,
216
- allowedTokenTypes,
217
- jwksTimeoutMs,
218
- })
219
- : null;
220
- if (payload && token && payload.sub) {
221
- const tunnelled = tunnelHeaders(req, { "x-nextgen-auth-token": token });
222
- return NextResponse.next({ request: { headers: tunnelled } });
212
+ const { auth, undecided, cookies } = await resolveSession({
213
+ bearer: bearerToken,
214
+ cookie: (name) => req.cookies.get(name)?.value,
215
+ requestUrl: req.url,
216
+ forwardedProto: req.headers.get("x-forwarded-proto"),
217
+ forwardedHost: req.headers.get("x-forwarded-host"),
218
+ }, verification);
219
+ let response;
220
+ if (auth.isAuthenticated) {
221
+ const tunnelled = tunnelHeaders(req, { "x-nextgen-auth-token": auth.session.token });
222
+ response = NextResponse.next({ request: { headers: tunnelled } });
223
223
  }
224
- // The backend issues opaque encrypted session tokens rather than JWTs.
225
- // Only fall back to /sessions/me validation when the token is definitively
226
- // not a JWT (non-JSON segments). A token with a valid JWT structure that
227
- // failed verification (bad sig, wrong typ/alg) must be rejected — never
228
- // accepted by a backend call that doesn't re-check the JWT claims.
229
- if (!payload && cookieToken && !isJwtShaped(cookieToken)) {
230
- const isValid = await validateOpaqueSessionToken(cookieToken, url, opaqueTokenTimeoutMs);
231
- if (isValid) {
232
- const tunnelled = tunnelHeaders(req, {
233
- "x-nextgen-auth-token": cookieToken,
234
- });
235
- return NextResponse.next({ request: { headers: tunnelled } });
236
- }
224
+ else if (undecided && matchesRoutes(pathname, protectedRoutes)) {
225
+ // The backend could not say whether the session is live. The user may well
226
+ // be signed in, so a protected route answers 503 rather than send them to
227
+ // the login page.
228
+ response = new NextResponse("Service Unavailable", { status: 503 });
237
229
  }
238
- const tunnelled = tunnelHeaders(req, { "x-nextgen-auth-token": "" });
239
- const staleNextgenCookies = req.cookies
240
- .getAll()
241
- .filter((c) => c.name.startsWith("__nextgen"));
242
- if (matchesRoutes(pathname, protectedRoutes)) {
243
- const loginUrl = new URL(loginPath, req.url);
244
- loginUrl.searchParams.set("next", pathname);
245
- const redirect = NextResponse.redirect(loginUrl, { status: 302 });
246
- for (const cookie of staleNextgenCookies) {
247
- redirect.cookies.delete(cookie.name);
248
- }
249
- return redirect;
230
+ else if (matchesRoutes(pathname, protectedRoutes)) {
231
+ response = NextResponse.redirect(loginRedirectUrl(loginPath, req.url), { status: 302 });
232
+ }
233
+ else {
234
+ const tunnelled = tunnelHeaders(req, { "x-nextgen-auth-token": "" });
235
+ response = NextResponse.next({ request: { headers: tunnelled } });
250
236
  }
251
- const response = NextResponse.next({ request: { headers: tunnelled } });
252
- for (const cookie of staleNextgenCookies) {
253
- response.cookies.delete(cookie.name);
237
+ // A dead session's cookie is cleared so the browser stops replaying it, and a
238
+ // session of this project still under the shared name is copied to the
239
+ // project's own cookie. A live session that signs no one in here (an
240
+ // anonymous one, or one of another project) keeps its cookie, and so does one
241
+ // the backend gave no verdict on. The cookies go through the response's
242
+ // cookie jar, so a middleware composed around this one that sets a cookie of
243
+ // its own through it keeps them.
244
+ for (const cookie of cookies) {
245
+ response.cookies.set(cookie.name, cookie.value, {
246
+ path: "/",
247
+ httpOnly: true,
248
+ sameSite: "lax",
249
+ secure: cookie.secure,
250
+ ...(cookie.maxAge === undefined ? {} : { maxAge: cookie.maxAge }),
251
+ });
252
+ }
253
+ if (cookies.length > 0) {
254
+ response.headers.set("cache-control", SESSION_COOKIE_CACHE_CONTROL);
254
255
  }
255
256
  return response;
256
257
  }
@@ -265,7 +266,7 @@ async function handleAuth(req, opts) {
265
266
  * import { createProxy } from "@zitadel/sdk-next/middleware";
266
267
  *
267
268
  * const zitadel = configureZitadel({
268
- * projectId: "demo",
269
+ * projectId: process.env.NEXT_PUBLIC_ZITADEL_PROJECT_ID ?? "",
269
270
  * url: process.env.ZITADEL_URL,
270
271
  * });
271
272
  *
@@ -282,6 +283,12 @@ async function handleAuth(req, opts) {
282
283
  * export const middleware = proxy;
283
284
  * ```
284
285
  *
286
+ * Sessions are held to the handle's project unless `options.projectId` names
287
+ * another. `auth()` runs in the server runtime, where the middleware's handle
288
+ * may not be configured: it reads `ZITADEL_PROJECT_ID` /
289
+ * `NEXT_PUBLIC_ZITADEL_PROJECT_ID`, else a handle configured there. When the
290
+ * project lives only in this handle, pass it as `auth({ projectId })` too.
291
+ *
285
292
  * @param config - The SDK handle from `configureZitadel()`.
286
293
  * @param options - Route protection and JWT options.
287
294
  * @returns A middleware handler function.
@@ -289,6 +296,7 @@ async function handleAuth(req, opts) {
289
296
  export function createProxy(config, options = {}) {
290
297
  const mergedOptions = {
291
298
  ...options,
299
+ projectId: options.projectId ?? (config.projectId || undefined),
292
300
  proxyPath: config.proxyPath,
293
301
  url: config.url,
294
302
  };
package/dist/server.d.ts CHANGED
@@ -9,4 +9,5 @@
9
9
  export { auth } from "./auth.js";
10
10
  export type { AuthOptions } from "./auth.js";
11
11
  export { NextgenProvider } from "./provider.js";
12
+ export type { AuthResult, AuthState, UnauthState, NextgenSession, ClientAuthResult, NextgenMiddlewareOptions, } from "./types.js";
12
13
  //# sourceMappingURL=server.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,YAAY,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AAC7C,OAAO,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,YAAY,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AAC7C,OAAO,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAChD,YAAY,EACV,UAAU,EACV,SAAS,EACT,WAAW,EACX,cAAc,EACd,gBAAgB,EAChB,wBAAwB,GACzB,MAAM,YAAY,CAAC"}
package/dist/session.d.ts CHANGED
@@ -7,24 +7,37 @@ export type GetSessionOptions = {
7
7
  * on this page, else `"/__nextgen"` (the scaffold default).
8
8
  */
9
9
  proxyPath?: string;
10
+ /**
11
+ * The project this app signs its users in to. A session of another project
12
+ * reads as signed out. Defaults to the project from `configureZitadel()`
13
+ * when it has run on this page, else `NEXT_PUBLIC_ZITADEL_PROJECT_ID`. With
14
+ * none, nobody reads as signed in, as on the server.
15
+ */
16
+ projectId?: string;
10
17
  };
11
18
  /**
12
19
  * Reads the current session state from the browser — the supported way for
13
20
  * an app's own UI (header navigation, account menus) to know whether a user
14
21
  * is signed in and who they are.
15
22
  *
16
- * Fetches the same-origin `{proxyPath}/sessions/me` with credentials and
17
- * `cache: "no-store"` — the same read the `<zitadel-session>` card performs —
18
- * so the answer is the server's, not a client-side guess. Works on any page:
19
- * unlike server-side `auth()`, it does not require the route to be covered by
20
- * the middleware `matcher` (only the proxy path itself must be matched, which
21
- * the scaffolded request boundary always does), and it does not require
22
- * `configureZitadel()` to have run.
23
+ * Fetches the same-origin `{proxyPath}/sessions/me?project_id=…` with
24
+ * credentials and `cache: "no-store"` — the same read the `<zitadel-session>`
25
+ * card performs — so the answer is the server's, not a client-side guess.
26
+ * Works on any page: unlike server-side `auth()`, it does not require the
27
+ * route to be covered by the middleware `matcher` (only the proxy path itself
28
+ * must be matched, which the scaffolded request boundary always does).
29
+ *
30
+ * A session counts only in the app's own project: `options.projectId`, else
31
+ * the project of `configureZitadel()`, else `NEXT_PUBLIC_ZITADEL_PROJECT_ID`.
32
+ * With none of them, it reads as signed out without asking the server.
23
33
  *
24
34
  * - `200` with an authenticated user → `{ isAuthenticated: true, session }`
25
35
  * with the client-safe identity (`userId`, `email`, `name` — no token).
26
- * - `200` for an anonymous session, `401` with `auth.unauthorized`, or `404`
27
- * with `sess.not_found` → signed out.
36
+ * - `200` for an anonymous session, `401` with `auth.unauthorized`, `403`
37
+ * with `sess.project_mismatch`, or `404` with `sess.not_found` → signed out.
38
+ * The `403` answers a session of another project on the same server, and a
39
+ * `200` naming another project (from a server that predates the parameter)
40
+ * reads as signed out too.
28
41
  * - Any other response throws — including a framework's HTML 404 page from
29
42
  * a misrouted proxy: a failing proxy is a misconfiguration and must not
30
43
  * silently render as "signed out". Treat a rejection as "unknown" —
@@ -39,7 +52,10 @@ export type GetSessionOptions = {
39
52
  * const [auth, setAuth] = useState<ClientAuthResult>();
40
53
  * const [failed, setFailed] = useState(false);
41
54
  * useEffect(() => {
42
- * getSession().then(setAuth, () => setFailed(true));
55
+ * getSession({ projectId: process.env.NEXT_PUBLIC_ZITADEL_PROJECT_ID }).then(
56
+ * setAuth,
57
+ * () => setFailed(true),
58
+ * );
43
59
  * }, []);
44
60
  * if (failed) return <span role="alert">Session unavailable</span>;
45
61
  * if (!auth) return null;
@@ -1 +1 @@
1
- {"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAKnD,sCAAsC;AACtC,MAAM,MAAM,iBAAiB,GAAG;IAC9B;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+CG;AACH,wBAAsB,UAAU,CAAC,OAAO,GAAE,iBAAsB,GAAG,OAAO,CAAC,gBAAgB,CAAC,CA2D3F;AAED,YAAY,EAAE,gBAAgB,EAAE,eAAe,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAKnD,sCAAsC;AACtC,MAAM,MAAM,iBAAiB,GAAG;IAC9B;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IAEnB;;;;;OAKG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,CAAC;AA8BF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwDG;AACH,wBAAsB,UAAU,CAAC,OAAO,GAAE,iBAAsB,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAqE3F;AAED,YAAY,EAAE,gBAAgB,EAAE,eAAe,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC"}
package/dist/session.js CHANGED
@@ -1,23 +1,53 @@
1
1
  import { getZitadelConfig } from "@zitadel/api/config";
2
2
  /** Matches the `configureZitadel()` default so zero-config apps agree with the scaffold. */
3
3
  const DEFAULT_PROXY_PATH = "/__nextgen";
4
+ /**
5
+ * `NEXT_PUBLIC_ZITADEL_PROJECT_ID`, which Next.js inlines into client bundles
6
+ * at build time. Read defensively: where it was not inlined, `process` may not
7
+ * exist in the browser.
8
+ */
9
+ function publicProjectId() {
10
+ try {
11
+ return process.env.NEXT_PUBLIC_ZITADEL_PROJECT_ID || undefined;
12
+ }
13
+ catch {
14
+ return undefined;
15
+ }
16
+ }
17
+ let warnedMissingProject = false;
18
+ /** Says once per page why `getSession()` signs no one in: no project is known. */
19
+ function warnMissingProject() {
20
+ if (warnedMissingProject) {
21
+ return;
22
+ }
23
+ warnedMissingProject = true;
24
+ console.warn("[nextgen] getSession(): no project id is known, so no session counts as signed in. " +
25
+ "Pass `projectId` (e.g. process.env.NEXT_PUBLIC_ZITADEL_PROJECT_ID) or call " +
26
+ "configureZitadel() first.");
27
+ }
4
28
  /**
5
29
  * Reads the current session state from the browser — the supported way for
6
30
  * an app's own UI (header navigation, account menus) to know whether a user
7
31
  * is signed in and who they are.
8
32
  *
9
- * Fetches the same-origin `{proxyPath}/sessions/me` with credentials and
10
- * `cache: "no-store"` — the same read the `<zitadel-session>` card performs —
11
- * so the answer is the server's, not a client-side guess. Works on any page:
12
- * unlike server-side `auth()`, it does not require the route to be covered by
13
- * the middleware `matcher` (only the proxy path itself must be matched, which
14
- * the scaffolded request boundary always does), and it does not require
15
- * `configureZitadel()` to have run.
33
+ * Fetches the same-origin `{proxyPath}/sessions/me?project_id=…` with
34
+ * credentials and `cache: "no-store"` — the same read the `<zitadel-session>`
35
+ * card performs — so the answer is the server's, not a client-side guess.
36
+ * Works on any page: unlike server-side `auth()`, it does not require the
37
+ * route to be covered by the middleware `matcher` (only the proxy path itself
38
+ * must be matched, which the scaffolded request boundary always does).
39
+ *
40
+ * A session counts only in the app's own project: `options.projectId`, else
41
+ * the project of `configureZitadel()`, else `NEXT_PUBLIC_ZITADEL_PROJECT_ID`.
42
+ * With none of them, it reads as signed out without asking the server.
16
43
  *
17
44
  * - `200` with an authenticated user → `{ isAuthenticated: true, session }`
18
45
  * with the client-safe identity (`userId`, `email`, `name` — no token).
19
- * - `200` for an anonymous session, `401` with `auth.unauthorized`, or `404`
20
- * with `sess.not_found` → signed out.
46
+ * - `200` for an anonymous session, `401` with `auth.unauthorized`, `403`
47
+ * with `sess.project_mismatch`, or `404` with `sess.not_found` → signed out.
48
+ * The `403` answers a session of another project on the same server, and a
49
+ * `200` naming another project (from a server that predates the parameter)
50
+ * reads as signed out too.
21
51
  * - Any other response throws — including a framework's HTML 404 page from
22
52
  * a misrouted proxy: a failing proxy is a misconfiguration and must not
23
53
  * silently render as "signed out". Treat a rejection as "unknown" —
@@ -32,7 +62,10 @@ const DEFAULT_PROXY_PATH = "/__nextgen";
32
62
  * const [auth, setAuth] = useState<ClientAuthResult>();
33
63
  * const [failed, setFailed] = useState(false);
34
64
  * useEffect(() => {
35
- * getSession().then(setAuth, () => setFailed(true));
65
+ * getSession({ projectId: process.env.NEXT_PUBLIC_ZITADEL_PROJECT_ID }).then(
66
+ * setAuth,
67
+ * () => setFailed(true),
68
+ * );
36
69
  * }, []);
37
70
  * if (failed) return <span role="alert">Session unavailable</span>;
38
71
  * if (!auth) return null;
@@ -62,7 +95,12 @@ export async function getSession(options = {}) {
62
95
  while (proxyPath.endsWith("/")) {
63
96
  proxyPath = proxyPath.slice(0, -1);
64
97
  }
65
- const response = await fetch(`${proxyPath}/sessions/me`, {
98
+ const projectId = options.projectId || getZitadelConfig()?.projectId || publicProjectId();
99
+ if (!projectId) {
100
+ warnMissingProject();
101
+ return { isAuthenticated: false, session: null };
102
+ }
103
+ const response = await fetch(`${proxyPath}/sessions/me?project_id=${encodeURIComponent(projectId)}`, {
66
104
  cache: "no-store",
67
105
  credentials: "include",
68
106
  headers: { accept: "application/json" },
@@ -70,9 +108,10 @@ export async function getSession(options = {}) {
70
108
  // Only the backend's canonical error envelope is definitive signed-out.
71
109
  // A framework route, gateway, or WAF can also return 401/404; accepting the
72
110
  // status or content type alone would turn a broken proxy into signed-out.
73
- if (response.status === 401 || response.status === 404) {
111
+ if (response.status === 401 || response.status === 403 || response.status === 404) {
74
112
  const error = await readErrorEnvelope(response);
75
113
  const isSignedOut = (response.status === 401 && error?.code === "auth.unauthorized") ||
114
+ (response.status === 403 && error?.code === "sess.project_mismatch") ||
76
115
  (response.status === 404 && error?.code === "sess.not_found");
77
116
  if (isSignedOut) {
78
117
  return { isAuthenticated: false, session: null };
@@ -82,9 +121,10 @@ export async function getSession(options = {}) {
82
121
  throw new Error(`[nextgen] Session read failed: HTTP ${response.status} from ${proxyPath}/sessions/me`);
83
122
  }
84
123
  const session = (await response.json());
85
- // An anonymous session (no verified user factor yet) has no user_id —
86
- // for app chrome that is "not signed in".
87
- if (!session.user_id) {
124
+ // An anonymous session (no verified user factor yet) has no user_id, and a
125
+ // backend that predates `project_id` answers for any project: for app
126
+ // chrome, either is "not signed in".
127
+ if (!session.user_id || session.project_id !== projectId) {
88
128
  return { isAuthenticated: false, session: null };
89
129
  }
90
130
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zitadel/sdk-next",
3
- "version": "1.0.0-alpha.25",
3
+ "version": "1.0.0-alpha.28",
4
4
  "description": "Next.js helpers and mock auth UI for Zitadel",
5
5
  "homepage": "https://github.com/zitadel/nextgen/tree/main/packages/sdk-next#readme",
6
6
  "bugs": {
@@ -54,24 +54,26 @@
54
54
  "access": "public"
55
55
  },
56
56
  "dependencies": {
57
+ "@zitadel/api": "1.0.0-alpha.28",
58
+ "@zitadel/components": "1.0.0-alpha.28",
59
+ "@zitadel/sdk-core": "1.0.0-alpha.28",
57
60
  "server-only": "^0.0.1",
58
- "@zitadel/api": "1.0.0-alpha.25",
59
- "@zitadel/components": "1.0.0-alpha.25",
60
- "@zitadel/sdk-core": "1.0.0-alpha.25"
61
+ "ufo": "^1.6.4"
61
62
  },
62
63
  "peerDependencies": {
63
- "next": ">=15",
64
+ "next": ">=15.2.3",
64
65
  "react": ">=18",
65
66
  "react-dom": ">=18"
66
67
  },
67
68
  "devDependencies": {
69
+ "@edge-runtime/vm": "^5.0.0",
68
70
  "@testing-library/react": "^16.3.2",
69
71
  "@testing-library/user-event": "^14.6.1",
70
- "@types/node": "^25.6.0",
72
+ "@types/node": "^24.19.1",
71
73
  "@types/react": "^19.2.14",
72
74
  "@vitest/coverage-v8": "^4.1.11",
73
75
  "jsdom": "^29.0.2",
74
- "next": "^16.2.11",
76
+ "next": "^16.3.8",
75
77
  "react": "^19.2.8",
76
78
  "react-dom": "^19.2.8",
77
79
  "typescript": "^6.0.3",
@@ -81,6 +83,8 @@
81
83
  "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.build.json && node -e \"require('node:fs').copyFileSync('src/jsx.d.ts', 'dist/jsx.d.ts')\"",
82
84
  "typecheck": "tsc --noEmit -p tsconfig.lib.json && tsc --noEmit -p tsconfig.spec.json",
83
85
  "test": "vitest run",
84
- "test:all": "pnpm run test"
86
+ "test:all": "pnpm run test",
87
+ "lint": "biome check .",
88
+ "build-release": "node ../../scripts/release-clean.mjs local-dist && pnpm run build"
85
89
  }
86
90
  }