@zitadel/sdk-next 0.1.0-alpha.9 → 0.1.0

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.
Files changed (48) hide show
  1. package/README.md +207 -37
  2. package/dist/auth.d.ts +66 -13
  3. package/dist/auth.d.ts.map +1 -0
  4. package/dist/auth.js +162 -7
  5. package/dist/client.d.ts +27 -2
  6. package/dist/client.d.ts.map +1 -0
  7. package/dist/client.js +29 -9
  8. package/dist/components.d.ts +16 -0
  9. package/dist/components.d.ts.map +1 -0
  10. package/dist/components.js +15 -0
  11. package/dist/context.d.ts +21 -10
  12. package/dist/context.d.ts.map +1 -0
  13. package/dist/context.js +24 -8
  14. package/dist/index.d.ts +16 -9
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +13 -24
  17. package/dist/jsx.d.ts +5 -0
  18. package/dist/lib/jwt.d.ts +9 -0
  19. package/dist/lib/jwt.d.ts.map +1 -0
  20. package/dist/lib/jwt.js +7 -0
  21. package/dist/middleware.d.ts +19 -11
  22. package/dist/middleware.d.ts.map +1 -0
  23. package/dist/middleware.js +304 -9
  24. package/dist/provider.d.ts +52 -0
  25. package/dist/provider.d.ts.map +1 -0
  26. package/dist/provider.js +75 -0
  27. package/dist/react.d.ts +26 -0
  28. package/dist/react.d.ts.map +1 -0
  29. package/dist/react.js +24 -0
  30. package/dist/server.d.ts +13 -2
  31. package/dist/server.d.ts.map +1 -0
  32. package/dist/server.js +10 -7
  33. package/dist/session.d.ts +77 -0
  34. package/dist/session.d.ts.map +1 -0
  35. package/dist/session.js +156 -0
  36. package/dist/types.d.ts +8 -3
  37. package/dist/types.d.ts.map +1 -0
  38. package/dist/types.js +1 -1
  39. package/dist/useAuth.d.ts +17 -5
  40. package/dist/useAuth.d.ts.map +1 -0
  41. package/dist/useAuth.js +18 -7
  42. package/package.json +46 -31
  43. package/dist/chunk-2BFQLJQE.js +0 -27
  44. package/dist/chunk-4KENHIG4.js +0 -222
  45. package/dist/chunk-6F4PWJZI.js +0 -0
  46. package/dist/chunk-B7S6XMT3.js +0 -41
  47. package/dist/chunk-OCZMYSFX.js +0 -13
  48. package/dist/chunk-XTCHTAIQ.js +0 -12
package/README.md CHANGED
@@ -5,9 +5,37 @@ 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@latest
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
+
21
+ ## Entry points
22
+
23
+ | Import | Runs in | Provides |
24
+ | ------------------------------ | -------------------------------- | ------------------------------------------------ |
25
+ | `@zitadel/sdk-next/middleware` | Edge middleware | `nextgenMiddleware`, `createProxy` |
26
+ | `@zitadel/sdk-next/server` | Server Components, Route Handlers | `auth()`, `NextgenProvider` |
27
+ | `@zitadel/sdk-next/react` | Client Components | `useAuth()`, `AuthContextProvider` |
28
+ | `@zitadel/sdk-next/session` | Client Components | `getSession()` |
29
+ | `@zitadel/sdk-next/client` | Client boundary | Web-component registration, `configureZitadel()` |
30
+ | `@zitadel/sdk-next/components` | Client Components | `ZitadelLogin`, `ZitadelLogout`, `ZitadelSession` |
31
+
32
+ The package root re-exports the server and client surfaces together for use
33
+ in server modules. Do not import the root from a `"use client"` module: it
34
+ pulls in the server-only `auth()`, which fails the build with an import trace
35
+ (exactly how depends on the bundler's tree shaking — the supported client
36
+ imports are `/react` and `/session`). `NextgenProvider` is itself server-only
37
+ because it accepts the token-bearing `auth()` result — see section 3.
38
+
11
39
  ## Setup
12
40
 
13
41
  ### 1. Middleware
@@ -35,62 +63,180 @@ The middleware runs on every matched route and does three things in one pass:
35
63
 
36
64
  1. **Proxies** `/__nextgen/*` requests to the auth backend
37
65
  2. **Verifies** the session JWT via JWKS using the Web Crypto API
38
- 3. **Redirects** unauthenticated requests to `loginPath` for protected routes
66
+ 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
39
67
 
40
68
  ### 2. Reading auth in a Server Component
41
69
 
42
- ```ts
43
- import { auth } from "@zitadel/sdk-next";
70
+ ```tsx
71
+ import { auth } from "@zitadel/sdk-next/server";
72
+ import { Suspense } from "react";
44
73
 
45
- export default async function Page() {
74
+ export default function Page() {
75
+ return (
76
+ <Suspense fallback={<p>Loading…</p>}>
77
+ <Greeting />
78
+ </Suspense>
79
+ );
80
+ }
81
+
82
+ async function Greeting() {
46
83
  const session = await auth();
47
84
  if (!session.isAuthenticated) return <p>Not signed in</p>;
48
- return <p>Hello {session.session.email}</p>;
85
+ return <p>Hello {session.session.display ?? session.session.identifier}</p>;
49
86
  }
50
87
  ```
51
88
 
89
+ `auth()` reads the request, so whatever awaits it renders inside
90
+ `<Suspense>`: with Cache Components on (`cacheComponents`, which
91
+ `create-next-app` enables), Next.js refuses request data outside it.
92
+
93
+ `auth()` reads the token the middleware tunnelled into the request headers
94
+ and **verifies it before trusting it** — JWTs cryptographically via JWKS,
95
+ opaque tokens against the backend's `GET /sessions/me` (which also supplies
96
+ the user's identity). A forged `x-nextgen-auth-token` header sent directly by
97
+ a client to a route outside the middleware `matcher` is rejected. Two things
98
+ follow:
99
+
100
+ - `auth()` only reports a session on routes the `matcher` covers — on other
101
+ routes the token never reaches it (see section 4 for chrome on public
102
+ pages).
103
+ - If the middleware runs with custom verification options (`audience`,
104
+ `allowedAlgorithms`, …), pass the same values to `auth()` so both layers
105
+ accept the same tokens.
106
+
107
+ `session.token` is the raw session token, available server-side for calling
108
+ upstream APIs. Never forward it into client components yourself —
109
+ `NextgenProvider` strips it for you (next section).
110
+
52
111
  ### 3. Reading auth in a Client Component
53
112
 
54
- Wrap your app in `NextgenProvider` (e.g. in your root layout):
113
+ Seed the client tree once in your root layout (a Server Component), then
114
+ read the state with `useAuth()` anywhere below it:
55
115
 
56
116
  ```tsx
57
- import { NextgenProvider } from '@zitadel/sdk-next';
117
+ import { auth, NextgenProvider } from '@zitadel/sdk-next/server';
118
+ import { Suspense } from 'react';
58
119
 
59
- export default async function RootLayout({ children }) {
60
- const session = await auth();
120
+ export default function RootLayout({ children }) {
61
121
  return (
62
122
  <html>
63
123
  <body>
64
- <NextgenProvider value={session}>{children}</NextgenProvider>
124
+ <Suspense>
125
+ <AuthProvider>{children}</AuthProvider>
126
+ </Suspense>
65
127
  </body>
66
128
  </html>
67
129
  );
68
130
  }
131
+
132
+ async function AuthProvider({ children }) {
133
+ const session = await auth();
134
+ return <NextgenProvider session={session}>{children}</NextgenProvider>;
135
+ }
69
136
  ```
70
137
 
138
+ `NextgenProvider` converts the `auth()` result to the client-safe shape
139
+ **before** it crosses the server→client boundary: client components receive
140
+ `userId` / `email` / `name`, and the raw session token never enters the RSC
141
+ flight payload, where any script on the page could read it.
142
+
143
+ That strip only protects you while it runs on the server, which is why
144
+ `NextgenProvider` is server-only and **must not be re-exported through a
145
+ `"use client"` wrapper** (the common `providers.tsx` pattern): the wrapper
146
+ would become the client boundary, and its still-unstripped `session` prop —
147
+ token included — would serialise into the flight payload before the provider
148
+ ever ran. The `server-only` guard turns that wrapper into a build error
149
+ instead of a silent leak. To seed the context from client-side state (e.g. a
150
+ `getSession()` read), render `AuthContextProvider` from
151
+ `@zitadel/sdk-next/react` — it only accepts the token-less
152
+ `ClientAuthResult`.
153
+
71
154
  Then in any client component:
72
155
 
73
156
  ```tsx
74
157
  'use client';
75
- import { useAuth } from '@zitadel/sdk-next';
158
+ import { useAuth } from '@zitadel/sdk-next/react';
76
159
 
77
160
  export function UserBadge() {
78
161
  const auth = useAuth();
79
- return <span>{auth.isAuthenticated ? auth.session.email : 'Guest'}</span>;
162
+ return <span>{auth.isAuthenticated ? (auth.session.display ?? auth.session.identifier) : 'Guest'}</span>;
80
163
  }
81
164
  ```
82
165
 
83
- ### 4. Login page
166
+ `useAuth()` returns the same client-safe `ClientAuthResult` shape as
167
+ `getSession()` and sdk-nuxt's `useAuth()`. It reflects what the server knew
168
+ when the page rendered — which, like `auth()`, is only a live session on
169
+ routes the middleware `matcher` covers.
170
+
171
+ ### 4. Session state for your own UI (any page)
84
172
 
85
- The `<zitadel-login>` web component (from `@zitadel/components`) must be rendered client-side only. Split it into a server wrapper and a client widget:
173
+ `auth()` only sees a session on routes the middleware `matcher` covers, and the
174
+ scaffolded matcher covers just the proxy path and the protected routes — so a
175
+ header on a public page would always look signed out. For your app's own chrome
176
+ (header navigation, account menus), read the session client-side with
177
+ `getSession()` from `@zitadel/sdk-next/session`. It fetches the same-origin
178
+ `{proxyPath}/sessions/me?project_id=…` — the same read the `<zitadel-session>`
179
+ card performs — so it works on every page and the answer is the server's. The
180
+ project is the `projectId` option, else the project of `configureZitadel()`,
181
+ else `NEXT_PUBLIC_ZITADEL_PROJECT_ID`; with none, it reads as signed out:
182
+
183
+ ```tsx
184
+ 'use client';
185
+ import { useEffect, useState } from 'react';
186
+ import { getSession, type ClientAuthResult } from '@zitadel/sdk-next/session';
187
+
188
+ export function HeaderNav() {
189
+ // undefined = not yet known — render neutral chrome, not "Sign in".
190
+ const [auth, setAuth] = useState<ClientAuthResult>();
191
+ const [error, setError] = useState<Error>();
192
+ useEffect(() => {
193
+ getSession({ projectId: process.env.NEXT_PUBLIC_ZITADEL_PROJECT_ID }).then(setAuth, setError);
194
+ }, []);
195
+ if (error) return <span role="alert">Session unavailable</span>;
196
+ if (!auth) return null;
197
+ return auth.isAuthenticated ? (
198
+ <a href="/profile">{auth.session.display ?? auth.session.identifier ?? 'Account'}</a>
199
+ ) : (
200
+ <a href="/login">Sign in</a>
201
+ );
202
+ }
203
+ ```
204
+
205
+ A rejected `getSession()` means the state is *unknown* (broken proxy, network,
206
+ 5xx) — render a neutral or error state, never the signed-out CTAs.
207
+
208
+ A `200` with a non-empty `user_id` resolves to
209
+ `{ isAuthenticated: true, session: { userId, email, name } }` (client-safe —
210
+ no token); the canonical `401/auth.unauthorized`, `403/sess.project_mismatch`
211
+ (a session of another project on the same host), `404/sess.not_found`, and
212
+ anonymous sessions resolve to signed out. The request
213
+ and response are both marked no-store. Any other response — including malformed
214
+ JSON or a framework's HTML 404 page from a misrouted proxy — throws so a broken
215
+ proxy doesn't silently render as signed out. Sign-in and sign-out navigate
216
+ (`post-sign-in-url` / `post-sign-out-url`), so chrome re-reads on the next page
217
+ load without extra wiring; to react in place, listen for the widgets'
218
+ `zitadel-signout` / `zitadel-flow-complete` events.
219
+
220
+ ### 5. Login page
221
+
222
+ The `ZitadelLogin` component (the `@zitadel/sdk-react` wrapper, re-exported from `@zitadel/sdk-next/components`) must be rendered client-side only. Split it into a server wrapper and a client widget:
86
223
 
87
224
  ```tsx
88
225
  // app/login/page.tsx (server)
89
- import { auth } from '@zitadel/sdk-next';
226
+ import { auth } from '@zitadel/sdk-next/server';
90
227
  import { redirect } from 'next/navigation';
228
+ import { Suspense } from 'react';
91
229
  import { LoginWidget } from './widget';
92
230
 
93
- export default async function LoginPage() {
231
+ export default function LoginPage() {
232
+ return (
233
+ <Suspense>
234
+ <SignedOutOnly />
235
+ </Suspense>
236
+ );
237
+ }
238
+
239
+ async function SignedOutOnly() {
94
240
  const session = await auth();
95
241
  if (session.isAuthenticated) redirect('/admin');
96
242
  return <LoginWidget />;
@@ -104,15 +250,19 @@ import dynamic from 'next/dynamic';
104
250
 
105
251
  const ZitadelLogin = dynamic(
106
252
  async () => {
107
- await import('@zitadel/components');
253
+ // The /components entry registers the custom elements AND re-exports
254
+ // configureZitadel, so an app that only declares @zitadel/sdk-next
255
+ // resolves everything it needs (strict package managers would not
256
+ // resolve @zitadel/api or @zitadel/components transitively).
257
+ const { ZitadelLogin: Login, configureZitadel } = await import(
258
+ '@zitadel/sdk-next/components'
259
+ );
260
+ const project = configureZitadel({
261
+ projectId: process.env.NEXT_PUBLIC_ZITADEL_PROJECT_ID!,
262
+ proxyPath: '/__nextgen',
263
+ });
108
264
  return function ZitadelLoginElement() {
109
- return (
110
- <zitadel-login
111
- api-base="/__nextgen"
112
- project-id="demo"
113
- post-sign-in-url="/admin"
114
- />
115
- );
265
+ return <Login project={project} postSignInUrl="/admin" />;
116
266
  };
117
267
  },
118
268
  { ssr: false },
@@ -123,12 +273,33 @@ export function LoginWidget() {
123
273
  }
124
274
  ```
125
275
 
276
+ There is no `api-base` prop — pass the handle returned by `configureZitadel()`
277
+ as `project`.
278
+
279
+ ## Widget events
280
+
281
+ Each widget event reaches the callback in the last column, which receives the event's `detail` payload.
282
+
283
+ | Widget event | Fires when | Detail | Widgets | Callback |
284
+ | --- | --- | --- | --- | --- |
285
+ | `zitadel-flow-step` | Login shows a step, the first one included. | `{ step }` | Login | `onFlowStep` |
286
+ | `zitadel-flow-input` | The visitor edits a field. | `{ name, value }` | Login | `onFlowInput` |
287
+ | `zitadel-flow-complete` | The flow reaches a terminal step. | `{ behavior, redirect_uri?, handoff_token?, handoff_token_expires_at? }` | Login | `onFlowComplete` |
288
+ | `zitadel-flow-redirect` | The widget hands the browser to an identity provider; nobody is signed in yet. | `{ redirect_url, step }` | Login | `onFlowRedirect` |
289
+ | `zitadel-flow-error` | The flow reports an error. | `{ message }` | Login | `onFlowError` |
290
+ | `zitadel-signout` | The user signs out. | `{ display, identifier }` | Logout, Session | `onSignout` |
291
+
292
+ ```tsx
293
+ <ZitadelLogin project={project} theme="light" onFlowComplete={(detail) => track(detail)} />
294
+ ```
295
+
126
296
  ## Middleware options
127
297
 
128
298
  | Option | Type | Default | Description |
129
299
  | ------------------- | -------------------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------- |
130
- | `url` | `string` | `ZITADEL_URL` env | Full URL of the Zitadel auth backend |
131
- | `proxyPath` | `string` | `"/__nextgen"` | Path prefix proxied to the auth backend |
300
+ | `url` | `string` | `ZITADEL_URL` env | Full URL of the Zitadel auth backend. A trailing slash is ignored. |
301
+ | `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()` |
302
+ | `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` |
132
303
  | `protectedRoutes` | `string[]` | `[]` | Paths requiring a valid session. Trailing `*` matches sub-paths |
133
304
  | `ignoredRoutes` | `string[]` | `[]` | Paths skipped entirely — no JWT check, no tunnelling. Useful for webhooks or health checks. Trailing `*` matches sub-paths |
134
305
  | `loginPath` | `string` | `"/login"` | Where to redirect unauthenticated users |
@@ -136,17 +307,16 @@ export function LoginWidget() {
136
307
  | `allowedTokenTypes` | `string[]` | `["JWT", "at+JWT"]` | Accepted `typ` header values (case-insensitive). Set to `[]` to disable this check |
137
308
  | `clockSkewMs` | `number` | `5000` | Clock skew tolerance in ms for `exp`, `nbf`, `iat` |
138
309
  | `jwksTimeoutMs` | `number` | `5000` | Timeout in ms for JWKS endpoint requests. Token is rejected if the fetch exceeds this window |
310
+ | `opaqueTokenTimeoutMs` | `number` | `5000` | Timeout in ms for opaque (non-JWT) session validation via `GET /sessions/me`. Also accepted by `auth()` |
311
+ | `proxyTimeoutMs` | `number` | `5000` | Timeout in ms for upstream proxy requests; requests exceeding it abort with a network error |
312
+ | `jwtKey` | `string` | unset | Reserved for future use — local PEM public key for offline JWT verification |
139
313
  | `audience` | `string \| string[]` | not validated | Expected `aud` claim value(s). When omitted, audience is not checked |
140
314
 
141
315
  ## How JWT verification works
142
316
 
143
- 1. Bearer token from `Authorization` header is checked first; `__nextgen_session` cookie is the fallback
144
- 2. The JWT header is decoded to extract `kid` and `alg`
145
- 3. Tokens with an `alg` not in `allowedAlgorithms` (`RS256`, `ES256` by default) are rejected immediately — no JWKS fetch
146
- 4. Tokens with a `typ` not in `allowedTokenTypes` are rejected immediately
147
- 5. The public key is fetched from `{url}/oauth/v2/keys` (JWKS) using the Web Crypto API, with a 5 s timeout, and cached for 5 minutes per `kid`
148
- 6. The signature is verified **before** any claim checks
149
- 7. `iss` must be present and must equal `url` — tokens without an issuer are rejected
150
- 8. `exp` must be present and must be in the future (with `clockSkewMs` tolerance) — tokens without an expiry are rejected
151
- 9. `nbf` and `iat` are validated with `clockSkewMs` tolerance when present
152
- 10. The `x-nextgen-auth-token` header is stripped from all proxied requests to prevent internal state leakage
317
+ The verification pipeline is shared across SDKs and documented once in
318
+ [`@zitadel/sdk-core`](https://github.com/zitadel/nextgen/tree/main/packages/sdk-core#how-jwt-verification-works).
319
+ On top of that pipeline, the Next.js middleware adds:
320
+
321
+ 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
322
+ 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
@@ -1,17 +1,68 @@
1
- import { AuthResult } from '@zitadel/sdk-core/types';
2
-
1
+ import "server-only";
2
+ import type { AuthResult, NextgenMiddlewareOptions } from "./types.js";
3
+ /**
4
+ * Options for {@link auth}.
5
+ *
6
+ * These mirror the token-verification subset of the middleware options. When
7
+ * the middleware runs with custom verification settings (a non-default
8
+ * `audience`, `allowedAlgorithms`, …), pass the same values here — otherwise
9
+ * a token the middleware accepted can fail re-verification in `auth()` and
10
+ * render as signed out.
11
+ */
12
+ export type AuthOptions = Pick<NextgenMiddlewareOptions, "url" | "projectId" | "allowedAlgorithms" | "clockSkewMs" | "audience" | "allowedTokenTypes" | "jwksTimeoutMs" | "opaqueTokenTimeoutMs">;
3
13
  /**
4
14
  * Reads the auth state in a React Server Component or Next.js Route Handler.
5
15
  *
6
- * - **JWT tokens** are decoded locally (no backend round-trip).
7
- * - **Opaque encrypted tokens** have already been validated by the
8
- * middleware via `GET /sessions/me`. If the `x-nextgen-auth-token`
9
- * header is set, the session is authentic. Session details (email,
10
- * name) are not available server-side for opaque tokens — use the
11
- * `/__nextgen/sessions/me` proxy from a client component to fetch them.
16
+ * The session token arrives on the `x-nextgen-auth-token` request header,
17
+ * tunnelled there by the middleware — and `auth()` **verifies it before
18
+ * trusting it**:
19
+ *
20
+ * - **JWT tokens** are verified cryptographically (signature via JWKS,
21
+ * issuer, expiry, algorithm and type allow-lists) using the same rules and
22
+ * defaults as the middleware. The JWKS is cached in-process, so this does
23
+ * not add a backend round-trip after the first call.
24
+ * - **Opaque encrypted tokens** are validated against the backend's
25
+ * `GET /sessions/me`, which also supplies the user's identity (`userId`,
26
+ * `email`, `name`). The lookup is deduplicated per render pass via React
27
+ * `cache()`.
28
+ *
29
+ * The header alone is never proof of anything: on routes the middleware
30
+ * `matcher` does not cover, the middleware cannot refuse a forged
31
+ * client-supplied header, so `auth()` re-verifies every value it reads.
32
+ * A header value that fails verification is treated as signed out.
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
+ *
46
+ * **Matcher precondition (read this):** the middleware only tunnels the
47
+ * session token on routes covered by its `matcher`. On uncovered routes the
48
+ * cookie never reaches `auth()`, so `auth()` reports signed out even when a
49
+ * live session exists. Either extend the `matcher` to every route that calls
50
+ * `auth()`, or — for app chrome like headers and account menus — read the
51
+ * session client-side with `getSession()` from `@zitadel/sdk-next/session`,
52
+ * which works on any page.
53
+ *
54
+ * The backend URL comes from `options.url`, falling back to the same
55
+ * `ZITADEL_URL` environment variable the middleware uses. If the middleware
56
+ * runs with custom verification options (`audience`, `allowedAlgorithms`, …),
57
+ * pass the same values here; see {@link AuthOptions}.
58
+ *
59
+ * This module is server-only: importing it (or the package root) from a
60
+ * client component fails at build time. Client components read auth state
61
+ * from `@zitadel/sdk-next/react` (`useAuth()`) or
62
+ * `@zitadel/sdk-next/session` (`getSession()`).
12
63
  *
13
64
  * ```ts
14
- * import { auth } from "@zitadel/sdk-next";
65
+ * import { auth } from "@zitadel/sdk-next/server";
15
66
  *
16
67
  * export default async function Page() {
17
68
  * const session = await auth();
@@ -20,8 +71,10 @@ import { AuthResult } from '@zitadel/sdk-core/types';
20
71
  * }
21
72
  * ```
22
73
  *
23
- * @returns The current {@link AuthResult}.
74
+ * @param options - Optional verification settings; see {@link AuthOptions}.
75
+ * @returns The current {@link AuthResult}. `session.token` is the raw session
76
+ * token for calling upstream APIs server-side — never forward it into
77
+ * client components (the `NextgenProvider` strips it for you).
24
78
  */
25
- declare function auth(): Promise<AuthResult>;
26
-
27
- export { auth };
79
+ export declare function auth(options?: AuthOptions): Promise<AuthResult>;
80
+ //# sourceMappingURL=auth.d.ts.map
@@ -0,0 +1 @@
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,7 +1,162 @@
1
- import {
2
- auth
3
- } from "./chunk-B7S6XMT3.js";
4
- import "./chunk-OCZMYSFX.js";
5
- export {
6
- auth
7
- };
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";
5
+ import { headers } from "next/headers";
6
+ import { cache } from "react";
7
+ import { isJwtShaped, verifyJwt } from "./lib/jwt.js";
8
+ /**
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).
12
+ *
13
+ * Wrapped in React's `cache()` so that many components calling `auth()` in
14
+ * the same render pass share a single backend round-trip. Arguments are
15
+ * primitives on purpose — `cache()` memoises by argument identity.
16
+ */
17
+ const checkOpaqueSession = cache((token, issuerUrl, timeoutMs, projectId) => checkOpaqueSessionToken(token, issuerUrl, timeoutMs, projectId));
18
+ /**
19
+ * Reads the auth state in a React Server Component or Next.js Route Handler.
20
+ *
21
+ * The session token arrives on the `x-nextgen-auth-token` request header,
22
+ * tunnelled there by the middleware — and `auth()` **verifies it before
23
+ * trusting it**:
24
+ *
25
+ * - **JWT tokens** are verified cryptographically (signature via JWKS,
26
+ * issuer, expiry, algorithm and type allow-lists) using the same rules and
27
+ * defaults as the middleware. The JWKS is cached in-process, so this does
28
+ * not add a backend round-trip after the first call.
29
+ * - **Opaque encrypted tokens** are validated against the backend's
30
+ * `GET /sessions/me`, which also supplies the user's identity (`userId`,
31
+ * `email`, `name`). The lookup is deduplicated per render pass via React
32
+ * `cache()`.
33
+ *
34
+ * The header alone is never proof of anything: on routes the middleware
35
+ * `matcher` does not cover, the middleware cannot refuse a forged
36
+ * client-supplied header, so `auth()` re-verifies every value it reads.
37
+ * A header value that fails verification is treated as signed out.
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
+ *
51
+ * **Matcher precondition (read this):** the middleware only tunnels the
52
+ * session token on routes covered by its `matcher`. On uncovered routes the
53
+ * cookie never reaches `auth()`, so `auth()` reports signed out even when a
54
+ * live session exists. Either extend the `matcher` to every route that calls
55
+ * `auth()`, or — for app chrome like headers and account menus — read the
56
+ * session client-side with `getSession()` from `@zitadel/sdk-next/session`,
57
+ * which works on any page.
58
+ *
59
+ * The backend URL comes from `options.url`, falling back to the same
60
+ * `ZITADEL_URL` environment variable the middleware uses. If the middleware
61
+ * runs with custom verification options (`audience`, `allowedAlgorithms`, …),
62
+ * pass the same values here; see {@link AuthOptions}.
63
+ *
64
+ * This module is server-only: importing it (or the package root) from a
65
+ * client component fails at build time. Client components read auth state
66
+ * from `@zitadel/sdk-next/react` (`useAuth()`) or
67
+ * `@zitadel/sdk-next/session` (`getSession()`).
68
+ *
69
+ * ```ts
70
+ * import { auth } from "@zitadel/sdk-next/server";
71
+ *
72
+ * export default async function Page() {
73
+ * const session = await auth();
74
+ * if (!session.isAuthenticated) return <p>Not signed in</p>;
75
+ * return <p>Hello {session.session.userId}</p>;
76
+ * }
77
+ * ```
78
+ *
79
+ * @param options - Optional verification settings; see {@link AuthOptions}.
80
+ * @returns The current {@link AuthResult}. `session.token` is the raw session
81
+ * token for calling upstream APIs server-side — never forward it into
82
+ * client components (the `NextgenProvider` strips it for you).
83
+ */
84
+ export async function auth(options = {}) {
85
+ const headerStore = await headers();
86
+ const token = headerStore.get("x-nextgen-auth-token");
87
+ if (!token) {
88
+ return { isAuthenticated: false, session: null };
89
+ }
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
+ }
97
+ if (isJwtShaped(token)) {
98
+ const payload = await verifyJwt(token, {
99
+ issuerUrl: url,
100
+ allowedAlgorithms,
101
+ clockSkewMs,
102
+ audience,
103
+ allowedTokenTypes,
104
+ jwksTimeoutMs,
105
+ });
106
+ if (payload?.sub && jwtNamesProject(payload, projectId)) {
107
+ return {
108
+ isAuthenticated: true,
109
+ session: {
110
+ userId: payload.sub,
111
+ identifier: payload.email ?? null,
112
+ identifierProperty: null,
113
+ display: payload.name ?? null,
114
+ token,
115
+ },
116
+ };
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
+ }
122
+ // A middleware-tunnelled JWT always verifies (the middleware applied the
123
+ // same checks moments earlier). Reaching this branch means the header was
124
+ // NOT vetted by the middleware — a route outside the `matcher` received a
125
+ // client-forged header — or the JWKS endpoint is unreachable from this
126
+ // runtime, or auth() runs with verification options that diverge from the
127
+ // middleware's.
128
+ console.warn("[nextgen] auth(): rejected an x-nextgen-auth-token header that failed JWT " +
129
+ "verification. If this route is not covered by the middleware matcher, this was " +
130
+ "a forged client header; otherwise check that auth() and the middleware use the " +
131
+ `same verification options and that the JWKS endpoint at ${joinURL(url, "/auth/keys")} is reachable.`);
132
+ return { isAuthenticated: false, session: null };
133
+ }
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
+ };
149
+ }
150
+ return { isAuthenticated: false, session: null };
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,2 +1,27 @@
1
- export { ZitadelLogin, ZitadelLogout } from '@zitadel/components';
2
- export { ZitadelConfig, ZitadelProject, configureZitadel, getApi } from '@zitadel/api/config';
1
+ /**
2
+ * Registers the `<zitadel-login>`, `<zitadel-logout>` and `<zitadel-session>`
3
+ * Lit web components for use in Next.js apps.
4
+ *
5
+ * Import this inside a `"use client"` boundary (e.g. a dynamic import
6
+ * with `{ ssr: false }`) to register the custom elements with the
7
+ * browser's global registry:
8
+ *
9
+ * ```ts
10
+ * await import("@zitadel/sdk-next/client");
11
+ * ```
12
+ *
13
+ * On an edge runtime the components package resolves to an entry without the
14
+ * elements, so this entry still loads there.
15
+ *
16
+ * SDK configuration is done via `configureZitadel()`, re-exported here so
17
+ * a consuming app that only declares `@zitadel/sdk-next` as a direct
18
+ * dependency can configure the SDK without reaching into
19
+ * `@zitadel/api/config` (which strict package managers would not
20
+ * resolve). Call it inside the same `"use client"` boundary before the
21
+ * components mount.
22
+ */
23
+ import "@zitadel/components";
24
+ export { businessLocales } from "@zitadel/components";
25
+ export { configureZitadel, getApi } from "@zitadel/api/config";
26
+ export type { ZitadelConfig, ZitadelProject } from "@zitadel/api/config";
27
+ //# sourceMappingURL=client.d.ts.map
@@ -0,0 +1 @@
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"}