Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/session-state-for-app-chrome.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@zitadel/sdk-next": minor
"@zitadel/sdk-core": minor
"@zitadel/sdk-nuxt": patch
"@zitadel/cli": minor
---

Give the embedding app a supported way to read session state for its own chrome (header navigation, account menus) — previously the widgets read `GET /sessions/me` internally but the host page had no documented path to the same answer and kept rendering signed-out CTAs beside a live session.

- `@zitadel/sdk-next` ships a new `@zitadel/sdk-next/session` entry with `getSession()`: a client-side read of the same-origin `{proxyPath}/sessions/me` (the exact read `<zitadel-session>` performs). Works on any page — unlike server-side `auth()` it does not require the route to be covered by the middleware `matcher` — and returns the client-safe `ClientAuthResult` (`userId`/`email`/`name`, no token). 401, the backend's JSON 404, and anonymous sessions map to signed-out; other failures — including a framework's HTML 404 from a misrouted proxy — throw instead of silently rendering signed-out.
- The client-safe auth shapes (`ClientSession`, `ClientAuthState`, `ClientAuthResult`) move to `@zitadel/sdk-core` as the single source; `@zitadel/sdk-nuxt` re-exports them unchanged, so its `useAuth()` and sdk-next's `getSession()` now return the identical shape.
- CLI scaffold guidance (`AGENTS.md` managed section) and the generated profile pages now name each framework's session read: `getSession()` on Next, the auto-imported `useAuth()` composable on Nuxt, and the raw `/__nextgen/sessions/me` read for the SPA frameworks.
12 changes: 12 additions & 0 deletions apps/cli/src/lib/orca/patchers/rule/guidance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,16 @@ export function agentsGuidanceSection(ctx: PatchContext): string {
ctx.framework.id === "next"
? " React JSX types for the `<zitadel-*>` elements ship with the SDK — `custom-elements.d.ts` references `@zitadel/sdk-next/jsx`."
: "";
// The app's own chrome (header nav, account menus) needs a session read of
// its own — the widgets render their surfaces but never tell the host page
// whether someone is signed in. Name the framework's supported read path;
// the contract details live with each helper's own docs.
const sessionStateParagraph =
ctx.framework.id === "next"
? "Your app's own chrome (header navigation, account menus) reads session state with `getSession()` from `@zitadel/sdk-next/session` — a client-side read of the same-origin `/__nextgen/sessions/me` that the session card itself uses, so the answer is the server's (200 → identity, 401 → signed out). In Server Components on routes covered by the request-boundary `matcher`, `auth()` from `@zitadel/sdk-next/server` works too. Sign-in and sign-out navigate (`post-sign-in-url` / `post-sign-out-url`), so chrome re-reads on the next page load without extra wiring."
: ctx.framework.id === "nuxt"
? "Your app's own chrome (header navigation, account menus) reads session state with the auto-imported `useAuth()` composable — seeded from the server on every render by the scaffolded auth plugin. Sign-in and sign-out navigate (`post-sign-in-url` / `post-sign-out-url`), so the state is fresh on every page load."
: "Your app's own chrome (header navigation, account menus) can read session state from the same-origin `/__nextgen/sessions/me` — the same server-answered read the session card performs: fetch with `credentials: \"include\"`; 200 returns the signed-in identity (`user_id`, `name`, `email`), 401 means signed out. Sign-in and sign-out navigate (`post-sign-in-url` / `post-sign-out-url`), so chrome re-reads on the next page load.";
return `## Authentication (Zitadel)

This app's login is managed by Zitadel. Local config is the source of truth; never change auth behavior by editing generated route files.
Expand All @@ -85,6 +95,8 @@ The golden path:

Presentation, by contrast, is edited in the generated pages: they pin the sign-in widgets to \`variant="page"\` (full-page chrome); switch to \`variant="widget"\` to embed a card inside your own layout, and set \`theme\` (\`light\` | \`dark\` | \`auto\`) to pick the color scheme.${jsxTypesNote}

${sessionStateParagraph}

Machine-readable dialect (read these before authoring flow or schema edits):

- Flow files carry \`"$schema": "../meta/flow-definition.json"\` — the flow dialect spec (steps, actions and their kinds, transitions, reserved outcomes like \`user_not_found\`). Editors validate against it.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,9 @@ const ZitadelSession = dynamic(
const { configureZitadel } = await import("@zitadel/sdk-next/client");
// Build the SDK project handle and pass it to the session card via the
// \`project\` prop. The card reads identity from "/__nextgen/sessions/me"
// and exposes a Sign out action.
// and exposes a Sign out action. Your own components (a header, an
// account menu) can make the same read with getSession() from
// "@zitadel/sdk-next/session" to swap sign-in CTAs for account chrome.
const project = configureZitadel({
projectId: process.env.NEXT_PUBLIC_ZITADEL_PROJECT_ID ?? "",
proxyPath: "/__nextgen",
Expand Down
4 changes: 3 additions & 1 deletion apps/cli/src/lib/orca/patchers/rule/nuxt/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,9 @@ const project = useZitadelProject();
<main style="color-scheme: dark">
<ClientOnly>
<!-- variant="page" paints the session card's full-page chrome from design
tokens; variant="widget" embeds the card inside a layout you own. -->
tokens; variant="widget" embeds the card inside a layout you own.
Your own components (a header, an account menu) read the same session
state with the auto-imported useAuth() composable. -->
<zitadel-session variant="page" :project="project" post-sign-out-url="/login" />
</ClientOnly>
</main>
Expand Down
28 changes: 28 additions & 0 deletions apps/cli/tests/unit/lib/orca/patchers/rule/guidance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,34 @@ describe("guidance content", () => {
expect(agentsGuidanceSection(passwordCtx)).not.toContain("passkey ceremonies");
});

it("tells each framework how its own chrome reads session state", () => {
// Next: the client helper (works on any page), plus the server-side
// auth() with its matcher precondition.
const agents = agentsGuidanceSection(ctx);
expect(agents).toContain("@zitadel/sdk-next/session");
expect(agents).toContain("`matcher`");

// Nuxt: the composable the scaffolded auth plugin seeds — no Next helper.
const nuxtCtx = {
...ctx,
framework: { id: "nuxt", devPort: 3000, url: "http://localhost:3000" },
} as PatchContext;
const nuxtAgents = agentsGuidanceSection(nuxtCtx);
expect(nuxtAgents).toContain("useAuth()");
expect(nuxtAgents).not.toContain("@zitadel/sdk-next/session");

// SPA frameworks: no framework helper exists yet, so the guidance names
// the raw proxy read instead of a package that would not resolve.
const reactCtx = {
...ctx,
framework: { id: "react", devPort: 5173, url: "http://localhost:5173" },
} as PatchContext;
const reactAgents = agentsGuidanceSection(reactCtx);
expect(reactAgents).toContain("/__nextgen/sessions/me");
expect(reactAgents).not.toContain("@zitadel/sdk-next/session");
expect(reactAgents).not.toContain("useAuth()");
});

it("names the presentation knobs and, on Next, the shipped JSX types", () => {
const agents = agentsGuidanceSection(ctx);
expect(agents).toContain('variant="widget"');
Expand Down
26 changes: 26 additions & 0 deletions packages/sdk-core/src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,32 @@ export type UnauthState = { isAuthenticated: false; session: null };
/** Union of all possible auth states. */
export type AuthResult = AuthState | UnauthState;

/**
* The client-safe session exposed to app UI (headers, account menus).
* Identical to {@link NextgenSession} but omits `token` — the raw session
* token must never reach client-side JavaScript, whether through an SSR
* payload or a client-side fetch result.
*/
export type ClientSession = {
/** The user's unique identifier (`sub` claim). */
userId: string;
/** The user's email address, or `null` if not present. */
email: string | null;
/** The user's display name, or `null` if not present. */
name: string | null;
};

/** Client-safe auth state when the user is signed in. */
export type ClientAuthState = { isAuthenticated: true; session: ClientSession };

/**
* Union of all possible client-safe auth states. Returned by the client
* session reads (`useAuth()` in sdk-nuxt, `getSession()` in sdk-next).
* Token is intentionally absent — use the server-side helpers when the raw
* token is needed.
*/
export type ClientAuthResult = ClientAuthState | UnauthState;

/**
* Options passed to the SDK middleware factory (`nextgenMiddleware` in
* sdk-next, `createNextgenMiddleware` in sdk-nuxt).
Expand Down
46 changes: 45 additions & 1 deletion packages/sdk-next/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,51 @@ export function UserBadge() {
}
```

### 4. Login page
### 4. Session state for your own UI (any page)

`auth()` only sees a session on routes the middleware `matcher` covers, and the
scaffolded matcher covers just the proxy path and the protected routes — so a
header on a public page would always look signed out. For your app's own chrome
(header navigation, account menus), read the session client-side with
`getSession()` from `@zitadel/sdk-next/session`. It fetches the same-origin
`{proxyPath}/sessions/me` — the same read the `<zitadel-session>` card performs —
so it works on every page and the answer is the server's:

```tsx
'use client';
import { useEffect, useState } from 'react';
import { getSession, type ClientAuthResult } from '@zitadel/sdk-next/session';

export function HeaderNav() {
// undefined = not yet known — render neutral chrome, not "Sign in".
const [auth, setAuth] = useState<ClientAuthResult>();
const [error, setError] = useState<Error>();
useEffect(() => {
getSession().then(setAuth, setError);
}, []);
if (error) return <span role="alert">Session unavailable</span>;
if (!auth) return null;
return auth.isAuthenticated ? (
<a href="/profile">{auth.session.name ?? auth.session.email ?? 'Account'}</a>
) : (
<a href="/login">Sign in</a>
);
}
```

A rejected `getSession()` means the state is *unknown* (broken proxy, network,
5xx) — render a neutral or error state, never the signed-out CTAs.

A `200` with an authenticated user resolves to `{ isAuthenticated: true, session: { userId, email, name } }`
(client-safe — no token); `401`, the backend's JSON `404`, and anonymous
sessions resolve to signed out; any other response — including a framework's
HTML 404 page from a misrouted proxy — throws so a broken proxy doesn't
silently render as signed out. Sign-in and sign-out navigate (`post-sign-in-url` /
`post-sign-out-url`), so chrome re-reads on the next page load without extra
wiring; to react in place, listen for the widgets' `zitadel-signout` /
`zitadel-flow-complete` events.

### 5. Login page

The `<zitadel-login>` web component (from `@zitadel/components`) must be rendered client-side only. Split it into a server wrapper and a client widget:

Expand Down
6 changes: 5 additions & 1 deletion packages/sdk-next/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,17 @@
},
"./jsx": {
"types": "./dist/jsx.d.ts"
},
"./session": {
"types": "./dist/session.d.ts",
"import": "./dist/session.js"
}
},
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "tsup src/middleware.ts src/auth.ts src/context.tsx src/useAuth.ts src/types.ts src/index.ts src/server.ts src/client.ts --format esm --dts --clean --tsconfig tsconfig.build.json && node -e \"require('node:fs').copyFileSync('src/jsx.d.ts', 'dist/jsx.d.ts')\"",
"build": "tsup src/middleware.ts src/auth.ts src/context.tsx src/useAuth.ts src/types.ts src/session.ts src/index.ts src/server.ts src/client.ts --format esm --dts --clean --tsconfig tsconfig.build.json && node -e \"require('node:fs').copyFileSync('src/jsx.d.ts', 'dist/jsx.d.ts')\"",
"typecheck": "tsc --build tsconfig.json",
"test": "vitest run --passWithNoTests",
"lint": "eslint ."
Expand Down
129 changes: 129 additions & 0 deletions packages/sdk-next/src/__tests__/session.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { _resetConfigForTesting, configureZitadel } from "@zitadel/api/config";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { getSession } from "../session";

function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}

const fetchMock = vi.fn<typeof fetch>();

beforeEach(() => {
_resetConfigForTesting();
fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
});

afterEach(() => {
vi.unstubAllGlobals();
_resetConfigForTesting();
});

describe("getSession()", () => {
it("reads the default proxy path with credentials and maps the identity", async () => {
fetchMock.mockResolvedValue(
jsonResponse({
session_id: "sess-1",
project_id: "proj-1",
state: "active",
user_id: "user-1",
email: "bob@example.com",
name: "Bob",
}),
);

const result = await getSession();

expect(fetchMock).toHaveBeenCalledWith(
"/__nextgen/sessions/me",
expect.objectContaining({ credentials: "include" }),
);
expect(result).toEqual({
isAuthenticated: true,
session: { userId: "user-1", email: "bob@example.com", name: "Bob" },
});
});

it("nulls absent identity attributes instead of dropping the session", async () => {
fetchMock.mockResolvedValue(
jsonResponse({ session_id: "s", project_id: "p", state: "active", user_id: "user-2" }),
);

const result = await getSession();

expect(result).toEqual({
isAuthenticated: true,
session: { userId: "user-2", email: null, name: null },
});
});

it("treats an anonymous session (no user_id) as signed out", async () => {
fetchMock.mockResolvedValue(
jsonResponse({ session_id: "s", project_id: "p", state: "building", user_id: null }),
);

await expect(getSession()).resolves.toEqual({ isAuthenticated: false, session: null });
});

it.each([401, 404])("treats HTTP %i as the server's definitive signed-out", async (status) => {
fetchMock.mockResolvedValue(jsonResponse({ error: "no session" }, status));

await expect(getSession()).resolves.toEqual({ isAuthenticated: false, session: null });
});

it("throws on other failures instead of silently rendering signed-out", async () => {
fetchMock.mockResolvedValue(jsonResponse({ error: "boom" }, 502));

await expect(getSession()).rejects.toThrow(/HTTP 502/);
});

it("throws on a framework's HTML 404 — only the backend's JSON 404 means signed out", async () => {
// A misrouted proxy (e.g. matcher miss) yields the router's 404 page,
// not the backend's error details; that must be loud, not "signed out".
fetchMock.mockResolvedValue(
new Response("<!DOCTYPE html><h1>404</h1>", {
status: 404,
headers: { "content-type": "text/html" },
}),
);

await expect(getSession()).rejects.toThrow(/HTTP 404/);
});

it("strips trailing slashes from the proxy path before building the URL", async () => {
fetchMock.mockResolvedValue(jsonResponse({ user_id: "u" }));

await getSession({ proxyPath: "/__nextgen/" });

expect(fetchMock).toHaveBeenCalledWith("/__nextgen/sessions/me", expect.anything());
});

it("prefers an explicit proxyPath over the configured one", async () => {
configureZitadel({ projectId: "proj", proxyPath: "/configured" });
fetchMock.mockResolvedValue(jsonResponse({ user_id: "u" }));

await getSession({ proxyPath: "/explicit" });

expect(fetchMock).toHaveBeenCalledWith("/explicit/sessions/me", expect.anything());
});

it("falls back to the configureZitadel() proxy path when set", async () => {
configureZitadel({ projectId: "proj", proxyPath: "/configured" });
fetchMock.mockResolvedValue(jsonResponse({ user_id: "u" }));

await getSession();

expect(fetchMock).toHaveBeenCalledWith("/configured/sessions/me", expect.anything());
});

it("refuses to run server-side and points at auth()", async () => {
vi.stubGlobal("window", undefined);

await expect(getSession()).rejects.toThrow(/auth\(\)/);
expect(fetchMock).not.toHaveBeenCalled();
});
});
2 changes: 2 additions & 0 deletions packages/sdk-next/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@ export type { ProxyOptions, ProxyHandler } from "./middleware";
export { auth } from "./auth";
export { NextgenProvider, useAuthContext } from "./context";
export { useAuth } from "./useAuth";
export { getSession } from "./session";
export type { GetSessionOptions } from "./session";
Loading
Loading