Merge branch 'main' into fix/nx-cache-gitignored-generated-files

This commit is contained in:
Tim Möhlmann authored and GitHub committed 2026-06-16 11:52:46 +07:00
commit 3056962295
457 files changed
+18566 -10571

No files matched your search

+1 -1
View File
@@ -339,7 +339,7 @@ go test -count 1 -tags integration ./internal/api/grpc/management/integration_te
To run all available integration tests:
```bash
go test -count 1 -tags integration -parallel 1 $(go list -tags integration ./... | grep -e \"integration_test\" -e \"events_testing\")
go test -count 1 -tags integration -parallel 1 $(go list -tags integration ./... | grep -e \"integration_test\")
```
It is also possible to run the API in a debugger and run the integrations tests against it.
+13 -48
View File
@@ -1,59 +1,24 @@
# Security Policy
## Introduction
## Reporting Security Issues
At ZITADEL we are extremely grateful for security aware people who disclose vulnerabilities to us and the open source community.
All reports will be investigated by our team, and we will work with you closely to validate and fix vulnerabilities reported to us.
We take the security of ZITADEL very seriously. If you believe you have found a security vulnerability in ZITADEL, we encourage you to report it to us immediately.
We require that you keep vulnerabilities confidential until we are able to address them, since public disclosure of security vulnerabilities could put the ZITADEL community at risk.
**Please do not report security-related findings publicly via GitHub issues.** Public disclosure of a security vulnerability could put the ZITADEL community at risk. Instead, please use our official vulnerability disclosure process.
## Scope
### Vulnerability Disclosure Process
The scope of this policy applies to all security issues that concern our Product in form of Software in our [open source repositories](https://github.com/zitadel).
Vulnerabilities should be reported through our dedicated portal:
**[zitadel.com/vulnerability](https://zitadel.com/vulnerability)**
Out of scope are all websites and services operated by ZITADEL (CAOS Ltd.).
Please refer to the separate [vulnerability disclosure policy](https://zitadel.com/docs/legal/policies/vulnerability-disclosure-policy).
For more details on our vulnerability policy, including scope and expectations, please refer to our official policy document:
**[ZITADEL Vulnerability Disclosure Policy](https://trust.zitadel.com/resources?s=aw085mxrel3icmbmkphns3&name=zitadel-vulnerability-disclosure-policy)**
### Supported Versions
### Definitive Source
Supported are releases that are newer and not older than 6 months from our stable release. You can read more about the release cycle [here](https://zitadel.com/docs/product/release-cycle)
When in doubt, the authoritative and most up-to-date source for our security reporting information is always our `security.txt` file:
**[zitadel.com/.well-known/security.txt](https://zitadel.com/.well-known/security.txt)**
### Out of scope (what is NOT a security vulnerability)
---
- Disclosure of known public files or directories, e.g. robots.txt, files under .well-known, or files that are included in our public repositories (e.g. `go.mod`)
- DoS of users when [Lockout Policy is enabled](https://zitadel.com/docs/guides/manage/console/default-settings#lockout)
- You need help applying security related settings
- When messages sent by Zitadel are reprocessed by third-party clients that automatically change the content of the message.
## Reporting a vulnerability
To file an incident, please disclose it by e-mail to [security@zitadel.com](mailto:security@zitadel.com) including the following details of the vulnerability:
- Target: ZITADEL, Website (zitadel.com), ZITADEL Cloud (zitadel.cloud), Other (please describe)
- Type: For example DoS, authentication bypass, information disclosure, broken authorization, ...
- Description: Provide a detailed explanation of the issue, steps to reproduce, and assumptions you have made
- URL / Location (optional): The URL of the vulnerability
- Contact details (optional): In case we should contact you on a different channel
At the moment GPG encryption is no yet supported, however you may sign your message at will.
Your email will be acknowledged within 48 hours.
We will follow up within the next 3 business days indicating next steps in handling your report.
If you haven't received a response within 48 hours, or you didn't get a reply from our security team within the last 5 days, please contact [support@zitadel.com](mailto:support@zitadel.com).
Please inform us in your report whether we should mention your contribution.
We will not publish this information by default to protect your privacy.
## Disclosure Process
Our security team will follow the disclosure process:
1. We will acknowledge the receipt of your vulnerability report
2. Our security team will try to verify, reproduce, and determine the impact of your report
3. A member of our team will respond to either confirm or reject your report, including an explanation
4. Code will be audited to assess if the report uncovers similar issues
5. Fixes are prepared for the latest release
6. On the date that the fixes are applied, we will create a CVE and publish a [security advisory](https://github.com/zitadel/zitadel/security/advisories). Affected users of our Product, Services, or Website will be informed of the fix and required actions.
We think it is crucial to publish advisories `ASAP` as mitigations are ready. But due to the unknown nature of the disclosures the time frame can range from 7 to 90 days.
We appreciate your help in keeping ZITADEL and its community safe!
+2 -3
View File
@@ -6,7 +6,6 @@
"sources": [
"{workspaceRoot}/cmd/**/*.go",
"{workspaceRoot}/internal/**/*.go",
"{workspaceRoot}/backend/**/*.go",
"{workspaceRoot}/proto/**/*.go",
"{workspaceRoot}/pkg/**/*.go",
"{workspaceRoot}/main.go",
@@ -235,8 +234,8 @@
},
"commands": [
"wait-on --verbose --interval 2000 --simultaneous 1 --timeout 30m \"${ZITADEL_API_URL}/debug/ready\"",
"bash -c 'go test -race -count 1 -tags integration -timeout 60m -parallel 1 $(go list -tags integration ./... | grep -e \"integration_test\" -e \"events_testing\")'",
"go tool covdata textfmt -i=$GOCOVERDIR -pkg=github.com/zitadel/zitadel/internal/...,github.com/zitadel/zitadel/cmd/...,github.com/zitadel/zitadel/backend/... -o profile.api.test-integration.cov",
"bash -c 'go test -race -count 1 -tags integration -timeout 60m -parallel 1 $(go list -tags integration ./... | grep -e \"integration_test\")'",
"go tool covdata textfmt -i=$GOCOVERDIR -pkg=github.com/zitadel/zitadel/internal/...,github.com/zitadel/zitadel/cmd/... -o profile.api.test-integration.cov",
"nx run @zitadel/api:test-integration-stop"
]
},
+5 -1
View File
@@ -77,6 +77,7 @@ LogStore:
Enabled: true
Executions:
# Deprecated: use HTTPClient.DenyList
DenyList: # ZITADEL_EXECUTIONS_DENYLIST (comma separated list)
Projections:
@@ -129,4 +130,7 @@ OIDC:
DefaultLogoutURLV2: "/logout?post_logout_redirect=" # ZITADEL_OIDC_DEFAULTLOGOUTURLV2
SAML:
DefaultLoginURLV2: "/login?authRequest=" # ZITADEL_SAML_DEFAULTLOGINURLV2
DefaultLoginURLV2: "/login?authRequest=" # ZITADEL_SAML_DEFAULTLOGINURLV2
HTTPClient:
DenyList:
+47 -55
View File
@@ -1,69 +1,61 @@
import { source, versionSource } from '@/lib/source';
import { getAllDocPages, source, versionSource } from '@/lib/source';
import { ReactNode } from 'react';
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
import { baseOptions } from '@/lib/layout.shared';
import { buildCustomTree } from '@/lib/custom-tree';
import rawVersions from '@/content/versions.json';
const versions = rawVersions as any[];
import { VersionSelector } from '@/components/version-selector';
type DocVersion = { param: string };
type PageTree = typeof source.pageTree;
const versions = rawVersions as DocVersion[];
const versionParams = new Set(versions.map((version) => version.param));
const labelsMap = new Map<string, string>();
for (const page of getAllDocPages()) {
if (page.data.sidebar_label) {
labelsMap.set(page.url, page.data.sidebar_label);
}
}
const latestTree = buildCustomTree(source.pageTree, { labels: labelsMap });
const versionTreeCache = new Map<string, PageTree>();
function getVersionTree(currentVersion: string): PageTree {
const cached = versionTreeCache.get(currentVersion);
if (cached) return cached;
const children = (versionSource.pageTree as any).children || [];
const versionFolder = children.find((node: any) => {
if (node.type !== 'folder') return false;
if (node.index?.url && node.index.url.includes(`/${currentVersion}`)) return true;
return Boolean(node.children?.[0]?.url?.includes(`/${currentVersion}`));
});
const tree = versionFolder
? buildCustomTree(
{
name: versionFolder.name,
children: versionFolder.children,
} as any,
{
stripPrefix: `/${currentVersion}`,
labels: labelsMap,
},
)
: buildCustomTree(versionSource.pageTree, { labels: labelsMap });
versionTreeCache.set(currentVersion, tree);
return tree;
}
export default async function Layout(props: { children: ReactNode; params: Promise<{ slug?: string[] }> }) {
const params = await props.params;
const slug = params.slug || [];
// Determine current version and tail path
// If first slug segment matches a version param (e.g. v4.10), that's the version.
// Otherwise it's latest.
const versionParam = slug[0] && versions.find(v => v.param === slug[0]) ? slug[0] : undefined;
const currentVersion = versionParam || 'latest';
const allPages = [...source.getPages(), ...versionSource.getPages()];
const labelsMap = new Map<string, string>();
allPages.forEach(p => {
if (p.data.sidebar_label) {
labelsMap.set(p.url, p.data.sidebar_label);
}
});
let tree: typeof source.pageTree;
if (currentVersion !== 'latest') {
// Hoist the version folder to root to flatten sidebar
// versionSource.pageTree -> [ v4.10 folder, v4.9 folder ... ]
// We want the children of the specific version folder.
const children = (versionSource.pageTree as any).children || [];
const versionFolder = children.find((node: any) => {
if (node.type !== 'folder') return false;
// Check if the folder's index page belongs to this version
if (node.index?.url && node.index.url.includes(`/${currentVersion}`)) return true;
// Fallback: check first child if index missing
if (node.children?.[0]?.url?.includes(`/${currentVersion}`)) return true;
return false;
});
if (versionFolder) {
// Hoist children
const hoistedTree = {
name: versionFolder.name,
children: versionFolder.children,
} as any;
// Apply custom sidebar structure (from local sidebar-data.ts)
// We strip the prefix '/v4.10' so the lookup matches generic keys 'guides/...'
// The logs showed the URLs start with /[version] not /docs/[version]
const prefix = currentVersion === 'latest' ? '/docs' : `/${currentVersion}`;
tree = buildCustomTree(hoistedTree, {
stripPrefix: prefix,
labels: labelsMap
});
} else {
tree = buildCustomTree(versionSource.pageTree, { labels: labelsMap });
}
} else {
tree = buildCustomTree(source.pageTree, { labels: labelsMap });
}
const currentVersion = slug[0] && versionParams.has(slug[0]) ? slug[0] : 'latest';
const tree = currentVersion === 'latest' ? latestTree : getVersionTree(currentVersion);
return (
<DocsLayout
+5 -4
View File
@@ -1,4 +1,4 @@
import { getPageImage, getPage, source } from '@/lib/source';
import { generateAllDocParams, getPageImage, getPage, source } from '@/lib/source';
import {
DocsBody,
DocsPage,
@@ -38,11 +38,12 @@ export default async function Page(props: any) {
);
}
export const dynamicParams = true;
export const revalidate = 3600;
export const dynamicParams = false;
export const revalidate = false;
export const dynamic = 'force-static';
export async function generateStaticParams() {
return source.generateParams();
return generateAllDocParams();
}
export async function generateMetadata(
+1
View File
@@ -1,6 +1,7 @@
import { getLLMText, source } from '@/lib/source';
export const revalidate = false;
export const dynamic = 'force-static';
export async function GET() {
const scan = source.getPages().map(getLLMText);
+12 -6
View File
@@ -1,20 +1,24 @@
import { ImageResponse } from 'next/og';
import { notFound } from 'next/navigation';
import { source } from '@/lib/source';
import { getAllDocPages, getPage, getPageImage } from '@/lib/source';
import { generate as DefaultImage } from 'fumadocs-ui/og';
export const revalidate = false;
export const dynamicParams = false;
export const dynamic = 'force-static';
export async function GET(request: Request, context: any) {
const { slug } = context.params;
const page = source.getPage(slug.slice(0, -1));
export async function GET(_request: Request, context: any) {
const { slug } = await context.params;
if (slug[slug.length - 1] !== 'image.png') notFound();
const { page } = getPage(slug.slice(0, -1));
if (!page) notFound();
return new ImageResponse(
<DefaultImage
title={page.data.title}
description={page.data.description}
site="My App"
site="ZITADEL Docs"
/>,
{
width: 1200,
@@ -24,5 +28,7 @@ export async function GET(request: Request, context: any) {
}
export function generateStaticParams() {
return [];
return getAllDocPages().map((page) => ({
slug: getPageImage(page).segments,
}));
}
+19 -20
View File
@@ -1,27 +1,26 @@
import { MetadataRoute } from 'next';
import { source } from '@/lib/source';
export const revalidate = false;
export const dynamic = 'force-static';
export default function sitemap(): MetadataRoute.Sitemap {
const baseUrl = 'https://zitadel.com/docs';
const baseUrl = 'https://zitadel.com/docs';
const docsPages = source.getPages().map((page) => ({
url: `${baseUrl}${page.url === '/' ? '' : page.url}`,
lastModified: new Date(),
changeFrequency: 'weekly' as const,
priority: 0.8,
}));
const docsPages = source.getPages().map((page) => ({
url: `${baseUrl}${page.url === '/' ? '' : page.url}`,
changeFrequency: 'weekly' as const,
priority: 0.8,
}));
return [
{
url: baseUrl,
lastModified: new Date(),
changeFrequency: 'daily' as const,
priority: 1,
},
...docsPages,
].filter((item, index, self) =>
index === self.findIndex((t) => t.url === item.url)
);
return [
{
url: baseUrl,
changeFrequency: 'daily' as const,
priority: 1,
},
...docsPages,
].filter(
(item, index, self) => index === self.findIndex((t) => t.url === item.url),
);
}
+46 -37
View File
@@ -1,44 +1,53 @@
// components/github-code-block.tsx
import { CodeBlock, Pre } from 'fumadocs-ui/components/codeblock';
import { DynamicCodeBlock } from 'fumadocs-ui/components/dynamic-codeblock';
const REVALIDATE_SECONDS = 60 * 60 * 24;
function toRawGithubUrl(url: string): string {
const parsed = new URL(url);
if (parsed.protocol !== 'https:' || parsed.hostname !== 'github.com') {
throw new Error(`GithubCodeBlock only accepts https://github.com URLs, got: ${url}`);
}
const segments = parsed.pathname.split('/').filter(Boolean);
if (segments.length < 5 || segments[2] !== 'blob') {
throw new Error(`Unexpected GitHub URL shape, expected /<owner>/<repo>/blob/<ref>/<path>, got: ${url}`);
}
const [owner, repo, , ref, ...path] = segments;
return `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${path.join('/')}`;
}
function parseLineRange(hash: string): { start: number; end: number } | null {
const match = hash.match(/L(\d+)(?:-L(\d+))?/);
if (!match) return null;
const start = parseInt(match[1], 10);
const end = match[2] ? parseInt(match[2], 10) : start;
return { start, end };
}
function sliceAndDedent(code: string, range: { start: number; end: number }): string {
const selected = code.split('\n').slice(range.start - 1, range.end);
const minIndent = selected.reduce((min, line) => {
if (line.trim().length === 0) return min;
const indent = line.match(/^\s*/)?.[0].length ?? 0;
return Math.min(min, indent);
}, Infinity);
const dedent = minIndent === Infinity ? 0 : minIndent;
return selected.map(line => line.slice(dedent)).join('\n');
}
export async function GithubCodeBlock({ url }: { url: string }) {
// 1. Parse the URL to get the raw content URL and line fragments
const urlObj = new URL(url);
const rawUrl = url
.replace('github.com', 'raw.githubusercontent.com')
.replace('/blob/', '/');
// Extract line numbers from hash (e.g., #L10-L20 or #L5)
const lineMatch = urlObj.hash.match(/L(\d+)(?:-L(\d+))?/);
const startLine = lineMatch ? parseInt(lineMatch[1], 10) : null;
const endLine = lineMatch ? (lineMatch[2] ? parseInt(lineMatch[2], 10) : startLine) : null;
// 2. Fetch the content
const response = await fetch(rawUrl);
const rawUrl = toRawGithubUrl(url);
const response = await fetch(rawUrl, { next: { revalidate: REVALIDATE_SECONDS } });
if (!response.ok) {
throw new Error(`GithubCodeBlock failed to fetch ${rawUrl}: ${response.status} ${response.statusText}`);
}
let code = await response.text();
// 3. Optional: Extract specific lines
if (startLine !== null && endLine !== null) {
const lines = code.split('\n');
const selectedLines = lines.slice(startLine - 1, endLine);
// Remove common indentation (dedent)
const minIndent = selectedLines.reduce((min, line) => {
if (line.trim().length === 0) return min;
const indent = line.match(/^\s*/)?.[0].length ?? 0;
return Math.min(min, indent);
}, Infinity);
code = selectedLines
.map(line => line.slice(minIndent === Infinity ? 0 : minIndent))
.join('\n');
const range = parseLineRange(new URL(url).hash);
if (range) {
code = sliceAndDedent(code, range);
}
const lang = url.split('.').pop()?.split('#')[0] || 'text';
const lang = new URL(url).pathname.split('.').pop() || 'text';
return (
<CodeBlock lang={lang}>
<Pre>{code}</Pre>
</CodeBlock>
);
}
return <DynamicCodeBlock lang={lang} code={code} />;
}
+26
View File
@@ -0,0 +1,26 @@
import { Callout } from 'fumadocs-ui/components/callout';
import Link from 'next/link';
export function PreventLockout() {
return (
<Callout type="warn" title="Prevent Settings Misconfiguration Lockouts">
Login policy settings misconfigurations that occur during the testing phase can easily lead to a{' '}
<Link href="./legal/policies/account-lockout-policy">lockout</Link>. To ensure you don't lose access to your instance:
<ol className="list-decimal list-inside mt-2">
<li>
<strong>Generate a backup PAT:</strong> Create a{' '}
<Link href="./guides/integrate/service-accounts/personal-access-token">
Service Account Personal Access Token
</Link>{' '}
with the <Link href="./guides/manage/console/administrators"><code>IAM_OWNER</code></Link> role to revert any login UI misconfigurations using the API.
</li>
<li>
<strong>Add a second Instance Administrator:</strong> Always designate at least one{' '}
<Link href="./guides/manage/console/administrators">
<strong>second instance administrator</strong>
</Link>.
</li>
</ol>
</Callout>
);
}
@@ -24,9 +24,7 @@ Extend the claims by a hardcoded value.
<details open="">
<summary>Code example</summary>
```js reference
https://github.com/zitadel/actions/blob/main/examples/add_claim.js
```
<GithubCodeBlock url="https://github.com/zitadel/actions/blob/main/examples/add_claim.js" />
</details>
@@ -37,9 +35,7 @@ Extend the claims by dynamically reading metadata from a user and sets the pictu
<details open="">
<summary>{props.summary ? props.summary : 'Code example'}</summary>
```js reference
https://github.com/zitadel/actions/blob/main/examples/add_picture_claim_from_idp_metadata.js
```
<GithubCodeBlock url="https://github.com/zitadel/actions/blob/main/examples/add_picture_claim_from_idp_metadata.js" />
</details>
@@ -50,9 +46,7 @@ Extend the claims by dynamically reading metadata from an organization and sets
<details open="">
<summary>Code example</summary>
```js reference
https://github.com/zitadel/actions/blob/main/examples/org_metadata_claim.js
```
<GithubCodeBlock url="https://github.com/zitadel/actions/blob/main/examples/org_metadata_claim.js" />
</details>
@@ -63,9 +57,7 @@ Some products require specific role mapping from ZITADEL, no worries we got you
<details open="">
<summary>Code example</summary>
```js reference
https://github.com/zitadel/actions/blob/main/examples/custom_roles.js
```
<GithubCodeBlock url="https://github.com/zitadel/actions/blob/main/examples/custom_roles.js" />
</details>
@@ -76,9 +68,7 @@ There's even a possibility to use the metadata of organizations the user is gran
<details open="">
<summary>Code example</summary>
```js reference
https://github.com/zitadel/actions/blob/main/examples/custom_roles_org_metadata.js
```
<GithubCodeBlock url="https://github.com/zitadel/actions/blob/main/examples/custom_roles_org_metadata.js" />
</details>
@@ -98,9 +88,7 @@ Some products require specific role mapping from ZITADEL, no worries we got you
<details open="">
<summary>Code example</summary>
```js reference
https://github.com/zitadel/actions/blob/main/examples/set_custom_attribute.js
```
<GithubCodeBlock url="https://github.com/zitadel/actions/blob/main/examples/set_custom_attribute.js" />
</details>
@@ -111,9 +99,7 @@ Extend the attributes by dynamically reading metadata from an organization and s
<details open="">
<summary>Code example</summary>
```js reference
https://github.com/zitadel/actions/blob/main/examples/org_metadata_attribute.js
```
<GithubCodeBlock url="https://github.com/zitadel/actions/blob/main/examples/org_metadata_attribute.js" />
</details>
@@ -135,9 +121,7 @@ Useful if you trust the provided information or don't want the users to verify t
<details open="">
<summary>Code example</summary>
```js reference
https://github.com/zitadel/actions/blob/main/examples/verify_email.js
```
<GithubCodeBlock url="https://github.com/zitadel/actions/blob/main/examples/verify_email.js" />
</details>
@@ -155,9 +139,7 @@ Allows you to assign default roles to a user after the user was created or feder
<details open="">
<summary>Code example</summary>
```js reference
https://github.com/zitadel/actions/blob/main/examples/add_user_grant.js
```
<GithubCodeBlock url="https://github.com/zitadel/actions/blob/main/examples/add_user_grant.js" />
</details>
@@ -177,9 +159,7 @@ Adding metadata to users allows you to set default metadata on users.
<details open="">
<summary>Code example</summary>
```js reference
https://github.com/zitadel/actions/blob/main/examples/add_metadata.js
```
<GithubCodeBlock url="https://github.com/zitadel/actions/blob/main/examples/add_metadata.js" />
</details>
@@ -199,9 +179,7 @@ If you use [Okta as an identity provider](/guides/integrate/identity-providers/o
<details open="">
<summary>Code example</summary>
```js reference
https://github.com/zitadel/actions/blob/main/examples/okta_identity_provider.js
```
<GithubCodeBlock url="https://github.com/zitadel/actions/blob/main/examples/okta_identity_provider.js" />
</details>
@@ -212,9 +190,7 @@ If you use [Gitlab as an identity provider](/guides/integrate/identity-providers
<details open="">
<summary>Code example</summary>
```js reference
https://github.com/zitadel/actions/blob/main/examples/gitlab_identity_provider.js
```
<GithubCodeBlock url="https://github.com/zitadel/actions/blob/main/examples/gitlab_identity_provider.js" />
</details>
@@ -225,9 +201,7 @@ If you use [GitHub as an identity provider](/guides/integrate/identity-providers
<details open="">
<summary>Code example</summary>
```js reference
https://github.com/zitadel/actions/blob/main/examples/github_identity_provider.js
```
<GithubCodeBlock url="https://github.com/zitadel/actions/blob/main/examples/github_identity_provider.js" />
</details>
@@ -238,9 +212,7 @@ If you use a [generic OIDC identity provider](/guides/integrate/identity-provide
<details open="">
<summary>Code example</summary>
```js reference
https://github.com/zitadel/actions/blob/main/examples/set_idp_picture_metadata.js
```
<GithubCodeBlock url="https://github.com/zitadel/actions/blob/main/examples/set_idp_picture_metadata.js" />
</details>
@@ -251,9 +223,7 @@ If you use [Okta as an identity provider](/guides/integrate/identity-providers/o
<details open="">
<summary>Code example</summary>
```js reference
https://github.com/zitadel/actions/blob/main/examples/okta_saml_prefil_register_form.js
```
<GithubCodeBlock url="https://github.com/zitadel/actions/blob/main/examples/okta_saml_prefil_register_form.js" />
</details>
@@ -264,9 +234,7 @@ If you use [Microsoft Entra as SAML identity provider](/guides/integrate/identit
<details open="">
<summary>Code example</summary>
```js reference
https://github.com/zitadel/actions/blob/main/examples/entra_id_saml_prefil_register_form.js
```
<GithubCodeBlock url="https://github.com/zitadel/actions/blob/main/examples/entra_id_saml_prefil_register_form.js" />
</details>
@@ -277,9 +245,7 @@ If you use a [SAML identity provider like mocksaml](/guides/integrate/identity-p
<details open="">
<summary>Code example</summary>
```js reference
https://github.com/zitadel/actions/blob/main/examples/post_auth_saml.js
```
<GithubCodeBlock url="https://github.com/zitadel/actions/blob/main/examples/post_auth_saml.js" />
</details>
@@ -305,9 +271,7 @@ Execution paths might change based on the application initiating the authenticat
<details open="">
<summary>Code example</summary>
```js reference
https://github.com/zitadel/actions/blob/main/examples/execute_action_on_specific_app.js
```
<GithubCodeBlock url="https://github.com/zitadel/actions/blob/main/examples/execute_action_on_specific_app.js" />
</details>
@@ -327,9 +291,7 @@ Your action can also check for errors during the login process.
<details open="">
<summary>Code example</summary>
```js reference
https://github.com/zitadel/actions/blob/main/examples/post_auth_log.js
```
<GithubCodeBlock url="https://github.com/zitadel/actions/blob/main/examples/post_auth_log.js" />
</details>
@@ -342,7 +304,5 @@ Allows you to limit the user interaction. The error thrown will be shown to the
<details open="">
<summary>Code example</summary>
```js reference
https://github.com/zitadel/actions/blob/main/examples/throw_error.js
```
<GithubCodeBlock url="https://github.com/zitadel/actions/blob/main/examples/throw_error.js" />
</details>
+1 -3
View File
@@ -55,9 +55,7 @@ The object has the following fields and methods:
### Example
```js reference
https://github.com/zitadel/actions/blob/main/examples/make_api_call.js#L10-L20
```
<GithubCodeBlock url="https://github.com/zitadel/actions/blob/main/examples/make_api_call.js#L10-L20" />
## Log
@@ -87,21 +87,15 @@ Multiple examples of Actions that result in custom claims can be found in our [M
### Static values as custom claim
```javascript reference
https://github.com/zitadel/actions/blob/de69b56f6d0463817953b59a52ffd6afc6a366fb/examples/add_claim.js#L9-L11
```
<GithubCodeBlock url="https://github.com/zitadel/actions/blob/de69b56f6d0463817953b59a52ffd6afc6a366fb/examples/add_claim.js#L9-L11" />
### Metadata as custom claim
```javascript reference
https://github.com/zitadel/actions/blob/main/examples/add_metadata.js#L9-L15
```
<GithubCodeBlock url="https://github.com/zitadel/actions/blob/main/examples/add_metadata.js#L9-L15" />
### Format roles claims
```javascript reference
https://github.com/zitadel/actions/blob/main/examples/custom_roles.js#L20-L33
```
<GithubCodeBlock url="https://github.com/zitadel/actions/blob/main/examples/custom_roles.js#L20-L33" />
## Reserved Claims
@@ -11,4 +11,4 @@ By configuring a Custom Domain within ZITADEL, organizations can replace the def
This not only enhances the overall user experience but also reinforces the organization's brand presence. Additionally, Custom Domains can contribute to trust and credibility, as users are more likely to recognize and trust URLs associated with the organization rather than generic domains. Overall, ZITADEL's Custom Domain feature empowers organizations to tailor the authentication process to align with their brand identity and user expectations.
Learn how to [configure a Custom Domain in ZITADEL Cloud](/guides/manage/cloud/instances#add-custom-domain) or how to configure [Custom Domain when self-hosting](/self-hosting/manage/custom-domain).
Learn how to [configure a Custom Domain in ZITADEL Cloud](https://help.zitadel.com/customer-portal-instances#add-custom-domain) or how to configure [Custom Domain when self-hosting](/self-hosting/manage/custom-domain).
+6 -18
View File
@@ -86,9 +86,7 @@ flutter pub add oidc_default_store
Navigate to your `AndroidManifest.xml` at `<projectRoot>/android/app/src/main/AndroidManifest.xml` and add the following activity with your custom scheme.
```xml reference
https://github.com/zitadel/zitadel_flutter/blob/main/android/app/src/main/AndroidManifest.xml#L29-L38
```
<GithubCodeBlock url="https://github.com/zitadel/zitadel_flutter/blob/main/android/app/src/main/AndroidManifest.xml#L29-L38" />
Furthermore, for `secure_storage`, you need to set the minimum SDK version to 18
in `<projectRoot>/android/app/src/build.gradle`.
@@ -99,16 +97,12 @@ To reduce the commented default code, we will modify the `main.dart` file.
First, the `MyApp` class: it remains a stateless widget:
```dart reference
https://github.com/zitadel/zitadel_flutter/blob/main/lib/main.dart#L14-L28
```
<GithubCodeBlock url="https://github.com/zitadel/zitadel_flutter/blob/main/lib/main.dart#L14-L28" />
Second, the `MyHomePage` class will remain a stateful widget with
its title, we don't change any code here.
```dart reference
https://github.com/zitadel/zitadel_flutter/blob/main/lib/main.dart#L30-L37
```
<GithubCodeBlock url="https://github.com/zitadel/zitadel_flutter/blob/main/lib/main.dart#L30-L37" />
What we'll change now, is the `_MyHomePageState` class to enable
authentication via ZITADEL and remove the counter button of the starter application. We'll show the username of the authenticated user.
@@ -126,16 +120,12 @@ Then the builder method, which does show the login button if you're not
authenticated, a loading bar if the login process is going on and
your name if you are authenticated:
```dart reference
https://github.com/zitadel/zitadel_flutter/blob/main/lib/main.dart#L119-L159
```
<GithubCodeBlock url="https://github.com/zitadel/zitadel_flutter/blob/main/lib/main.dart#L119-L159" />
And finally the `_authenticate` method which calls the authorization endpoint,
then fetches the user info and stores the tokens into the secure storage.
```dart reference
https://github.com/zitadel/zitadel_flutter/blob/main/lib/main.dart#L45-L117
```
<GithubCodeBlock url="https://github.com/zitadel/zitadel_flutter/blob/main/lib/main.dart#L45-L117" />
Note that we have to use our http redirect URL for web applications or otherwise use our custom scheme for Android and iOS devices.
To setup other platforms, read the documentation of the [Flutter Web Auth](https://pub.dev/packages/flutter_web_auth_2).
@@ -143,9 +133,7 @@ To setup other platforms, read the documentation of the [Flutter Web Auth](https
To ensure our application catches the callback URL, you have to create a `auth.html` file in the `/web`
folder with the following content:
```html reference
https://github.com/zitadel/zitadel_flutter/blob/main/web/auth.html
```
<GithubCodeBlock url="https://github.com/zitadel/zitadel_flutter/blob/main/web/auth.html" />
Now, you can run your application for iOS and Android devices with
+3 -9
View File
@@ -61,9 +61,7 @@ go get -u github.com/zitadel/zitadel-go/v3
Create a new go file with the content below. This will create an application with a home and profile page.
```go reference
https://github.com/zitadel/zitadel-go/blob/next/example/app/app.go
```
<GithubCodeBlock url="https://github.com/zitadel/zitadel-go/blob/next/example/app/app.go" />
This will basically set up everything. So let's look at some parts of the code.
@@ -108,17 +106,13 @@ Now create two HTML files in the new `templates` folder and copy the content of
The home page will display a short welcome message and allow the user to manually start the login process.
```go reference
https://github.com/zitadel/zitadel-go/blob/next/example/app/templates/home.html
```
<GithubCodeBlock url="https://github.com/zitadel/zitadel-go/blob/next/example/app/templates/home.html" />
**profile.html**
The profile page will display the Userinfo from the authentication context and allow the user to logout.
```go reference
https://github.com/zitadel/zitadel-go/blob/next/example/app/templates/profile.html
```
<GithubCodeBlock url="https://github.com/zitadel/zitadel-go/blob/next/example/app/templates/profile.html" />
### Start your application
+1 -3
View File
@@ -94,9 +94,7 @@ If authorization is required, the token must not be expired and the API has to b
For tests, we will use a Personal Access Token.
```go reference
https://github.com/zitadel/zitadel-go/blob/next/example/api/http/main.go
```
<GithubCodeBlock url="https://github.com/zitadel/zitadel-go/blob/next/example/api/http/main.go" />
You will need to provide some values for the program to run:
- `domain`: Your ZITADEL Custom Domain, e.g. https://my-domain.zitadel.cloud
@@ -3,7 +3,6 @@ title: Secure Java Spring Boot API Application with ZITADEL
description: "Secure Java Spring Boot APIs using OAuth 2.0 Token Introspection with ZITADEL."
sidebar_label: Java Spring Boot
---
import { GithubCodeBlock } from '@/components/github-code-block';
This integration guide shows you how to integrate **ZITADEL** into your Java Spring Boot API. It demonstrates how to secure your API using
OAuth 2 Token Introspection.
@@ -45,7 +44,7 @@ into Spring Security `authiorities`, which can be used later on to determine the
So in your application, create a `support/zitadel` package and in there the `CustomAuthorityOpaqueTokenIntrospector.java`:
<GithubCodeBlock url="https://github.com/zitadel/zitadel-java/blob/main/api/src/main/java/demo/app/support/zitadel/CustomAuthorityOpaqueTokenIntrospector.java"></GithubCodeBlock>
<GithubCodeBlock url="https://github.com/zitadel/zitadel-java/blob/main/api/src/main/java/demo/app/support/zitadel/CustomAuthorityOpaqueTokenIntrospector.java" />
### Application server settings
@@ -55,11 +54,11 @@ In a new `config` package, create the `WebSecurityConfig.java`.
This class will take care of the authorization by require the calls on `/api/tasks` to be authorized. Any other endpoint will be public by default.
It will also use the just created CustomAuthorityOpaqueTokenIntrospector for the introspection call:
<GithubCodeBlock url="https://github.com/zitadel/zitadel-java/blob/main/api/src/main/java/demo/app/config/WebSecurityConfig.java"></GithubCodeBlock>
<GithubCodeBlock url="https://github.com/zitadel/zitadel-java/blob/main/api/src/main/java/demo/app/config/WebSecurityConfig.java" />
For the authorization (and the server in general) to work, the application needs some settings, so please provide the following to your `application.yml` (resources folder):
<GithubCodeBlock url="https://github.com/zitadel/zitadel-java/blob/main/api/src/main/resources/application.yml"></GithubCodeBlock>
<GithubCodeBlock url="https://github.com/zitadel/zitadel-java/blob/main/api/src/main/resources/application.yml" />
Note that the `introspection-uri`, `client-id` and `client-secret` are only placeholders. You can either change them in here using the values provided by ZITADEL
or pass them later on as arguments when starting the application.
@@ -75,7 +74,7 @@ If authorization is required, the token must not be expired and the API has to b
For tests we will use a Personal Access Token or the [Java Spring web example](../login/java-spring).
<GithubCodeBlock url="https://github.com/zitadel/zitadel-java/blob/main/api/src/main/java/demo/app/api/ExampleController.java"></GithubCodeBlock>
<GithubCodeBlock url="https://github.com/zitadel/zitadel-java/blob/main/api/src/main/java/demo/app/api/ExampleController.java" />
## Test API
@@ -8,7 +8,6 @@ import AppJWT from '../imports/_app_jwt.mdx';
import ServiceAccountJWT from '../imports/_serviceaccount_jwt.mdx';
import ServiceAccountRole from '../imports/_serviceaccount_role.mdx';
import SetupPython from '../imports/_setup_python.mdx';
import { GithubCodeBlock } from '@/components/github-code-block';
This integration guide demonstrates the recommended way to incorporate ZITADEL into your Django Python application.
It explains how to check the token validity in the API and how to check for permissions.
@@ -63,7 +62,7 @@ For this example we need the following dependencies:
For the dependencies we need a requirements.txt-file with the following content:
<GithubCodeBlock url="https://github.com/zitadel/example-python-django-oauth/blob/main/requirements.txt"></GithubCodeBlock>
<GithubCodeBlock url="https://github.com/zitadel/example-python-django-oauth/blob/main/requirements.txt" />
Then install all dependencies with:
```bash
@@ -81,7 +80,7 @@ django-admin startproject myapi .
There is info needed for the introspection calls, which we put into the settings.py:
<GithubCodeBlock url="https://github.com/zitadel/example-python-django-oauth/blob/main/myapi/settings.py#L125-L133"></GithubCodeBlock>
<GithubCodeBlock url="https://github.com/zitadel/example-python-django-oauth/blob/main/myapi/settings.py#L125-L133" />
and create a ".env"-file in the root folder with the settings as an example:
```bash
@@ -103,16 +102,16 @@ API_PRIVATE_KEY_FILE_PATH = '/tmp/example/250719519163548112.json'
To validate the tokens, we need a validator which can be called in the event of API-calls.
validator.py:
<GithubCodeBlock url="https://github.com/zitadel/example-python-django-oauth/blob/main/myapi/validator.py"></GithubCodeBlock>
<GithubCodeBlock url="https://github.com/zitadel/example-python-django-oauth/blob/main/myapi/validator.py" />
### Requests and URLs
We define 3 different endpoints which differ in terms of requirements.
views.py:
<GithubCodeBlock url="https://github.com/zitadel/example-python-django-oauth/blob/main/myapi/views.py"></GithubCodeBlock>
<GithubCodeBlock url="https://github.com/zitadel/example-python-django-oauth/blob/main/myapi/views.py" />
To handle endpoints the urls have to be added to the urls.py:
<GithubCodeBlock url="https://github.com/zitadel/example-python-django-oauth/blob/main/myapi/urls.py"></GithubCodeBlock>
<GithubCodeBlock url="https://github.com/zitadel/example-python-django-oauth/blob/main/myapi/urls.py" />
### DB
@@ -51,9 +51,7 @@ In my external system for example Splunk I want to be able to get an information
1. Define an action that logs successful and failed login to your stdout.
Make sure the name of the action is the same as of the function in the script.
```ts reference
https://github.com/zitadel/actions/blob/main/examples/post_auth_log.js
```
<GithubCodeBlock url="https://github.com/zitadel/actions/blob/main/examples/post_auth_log.js" />
2. Add the action to the following Flows and Triggers
- Flow: Internal Authentication - Trigger: Post Authentication
- Flow: External Authentication - Trigger: Post Authentication
@@ -71,9 +69,7 @@ You want to send a request to an endpoint each time after an authentication (suc
1. Define an action that calls API endpoint.
Make sure the name of the action is the same as of the function in the script.
Example how to call an API endpoint:
```ts reference
https://github.com/zitadel/actions/blob/main/examples/make_api_call.js
```
<GithubCodeBlock url="https://github.com/zitadel/actions/blob/main/examples/make_api_call.js" />
2. Add the action to the following flows and triggers
- Flow: Internal Authentication - Trigger: Post Authentication
- Flow: External Authentication - Trigger: Post Authentication
@@ -11,6 +11,4 @@ import PrefillAction from './_prefill_action.mdx';
<PrefillAction components={props.components} fields="specific fields like firstname, lastname and email verified" provider="your providers"/>
This action is an example for OKTA. You can also use it for any other provider
```js reference
https://github.com/zitadel/actions/blob/main/examples/okta_identity_provider.js
```
<GithubCodeBlock url="https://github.com/zitadel/actions/blob/main/examples/okta_identity_provider.js" />
@@ -125,6 +125,4 @@ Click **Microsoft Entra**
<PrefillAction components={props.components} fields="username, firstname, lastname, displayname, email and email verified" provider="Entra"/>
```js reference
https://github.com/zitadel/actions/blob/main/examples/entra_id_saml_prefil_register_form.js
```
<GithubCodeBlock url="https://github.com/zitadel/actions/blob/main/examples/entra_id_saml_prefil_register_form.js" />
@@ -86,6 +86,4 @@ ZITADEL ensures that at least the `openid`-scope is always sent.
<PrefillAction components={props.components} fields="firstname and lastname" provider="GitHub"/>
```js reference
https://github.com/zitadel/actions/blob/main/examples/github_identity_provider.js
```
<GithubCodeBlock url="https://github.com/zitadel/actions/blob/main/examples/github_identity_provider.js" />
@@ -84,6 +84,4 @@ ZITADEL ensures that at least the `openid`-scope is always sent.
<PrefillAction components={props.components} fields="firstname and lastname" provider="GitLab"/>
```js reference
https://github.com/zitadel/actions/blob/main/examples/gitlab_identity_provider.js
```
<GithubCodeBlock url="https://github.com/zitadel/actions/blob/main/examples/gitlab_identity_provider.js" />
@@ -61,6 +61,15 @@ the format specified in [RFC7517](https://datatracker.ietf.org/doc/html/rfc7517)
*Example: `https://issuer.test.internal/keys`*
### Audience (optional)
The expected audience (`aud` claim) in the JWT. If set, ZITADEL will validate that the JWT contains this audience.
It's recommended to set this to ensure the JWT is intended to be used by ZITADEL.
If the JWT contains multiple audiences, the configured audience must be one of them.
If no audience is configured, ZITADEL will not validate the `aud` claim.
*Example: `my-client`*
### JWT endpoint
Where ZITADEL redirects users to retrieve the JWT.
@@ -145,7 +154,7 @@ You have an existing application which exists in an internal network which handl
application is built using ZITADEL but the userbase of the existing application needs to be able to open the new
application from within the existing one without creating a new account or having to sign in.
- Existing application: `apps.test.com/existing/`
- Existing application: `apps.test.com/existing/` (also used as audience)
- Existing internal auth service: `issuer.test.internal`
- New application: `new.test.com`
- ZITADEL Login UI: `accounts.test.com`
@@ -163,6 +172,7 @@ application from within the existing one without creating a new account or havin
- Keys endpoint: `issuer.test.internal/keys`
- Header name: `x-custom-tkn`
- Issuer: `issuer.test.internal`
- Audience: `apps.test.com/existing/`
#### Result flow
@@ -78,6 +78,4 @@ import TestSetup from './_test_setup.mdx';
<PrefillAction components={props.components} fields="firstname, lastname, username, email and email verified" provider="LinkedIn"/>
```js reference
https://github.com/zitadel/actions/blob/main/examples/linkedin_identity_provider.js
```
<GithubCodeBlock url="https://github.com/zitadel/actions/blob/main/examples/linkedin_identity_provider.js" />
@@ -77,6 +77,4 @@ in addition to the client secret.
<PrefillAction components={props.components} fields="firstname, lastname and email verified" provider="Okta"/>
```js reference
https://github.com/zitadel/actions/blob/main/examples/okta_identity_provider.js
```
<GithubCodeBlock url="https://github.com/zitadel/actions/blob/main/examples/okta_identity_provider.js" />
@@ -118,6 +118,4 @@ You can also fill the optional fields if needed:
<PrefillAction components={props.components} fields="username, firstname, lastname, email and email verified" provider="OKTA"/>
```js reference
https://github.com/zitadel/actions/blob/main/examples/okta_saml_prefil_register_form.js
```
<GithubCodeBlock url="https://github.com/zitadel/actions/blob/main/examples/okta_saml_prefil_register_form.js" />
@@ -210,9 +210,7 @@ If your application requires a custom role structure, [ZITADEL actions](/apis/ac
<details open="open">
<summary>Example on github</summary>
```js reference
https://github.com/zitadel/actions/blob/main/examples/custom_roles.js
```
<GithubCodeBlock url="https://github.com/zitadel/actions/blob/main/examples/custom_roles.js" />
</details>
@@ -118,7 +118,7 @@ Payload:
* `sub`: The application. Set this to the same value as `userId`.
* `aud`: Your [Custom Domain](../../../concepts/features/custom-domain).
* `iat`: The Unix timestamp when the JWT is created/signed (must be no older than 1 hour).
* `exp`: The Unix timestamp when this assertion expires.
* `exp`: The Unix timestamp when this assertion expires. The value of `exp` is strictly enforced. However, if `exp` is set to more than 1 hour in the future, `iat` will take precedence (the JWT will stop working once the `iat` timestamp is older than 1 hour).
For further details, see the [JWT with private key](/apis/openidoauth/authn-methods#jwt-with-private-key) API reference.
@@ -165,7 +165,7 @@ With the encoded JWT from the previous step, craft a POST request to ZITADEL's t
```bash
curl --request POST \
--url https:/$CUSTOM-DOMAIN/oauth/v2/token \
--url https://${CUSTOM_DOMAIN}/oauth/v2/token \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer \
--data scope='openid' \
@@ -235,4 +235,4 @@ By following these steps and adhering to best practices, you can securely manage
* [JWT with private key](/apis/openidoauth/authn-methods#jwt-with-private-key) API reference
* [Accessing ZITADEL API](../zitadel-apis/access-zitadel-apis)
* [Token introspection with private key JWT](../token-introspection/private-key-jwt)
* [Token introspection with private key JWT](../token-introspection/private-key-jwt)
@@ -41,9 +41,7 @@ go get -u github.com/zitadel/zitadel-go/v3
Create a new go file with the content below. This will create a application and call its `GetMyOrg` function on the ManagementService.
The SDK will make sure you will have access to the API by retrieving a Bearer Token using JWT Profile with the provided scopes (`openid` and `urn:zitadel:iam:org:project:id:zitadel:aud`).
```go reference
https://github.com/zitadel/zitadel-go/blob/next/example/client/cli/cli.go
```
<GithubCodeBlock url="https://github.com/zitadel/zitadel-go/blob/next/example/client/cli/cli.go" />
### Test
@@ -1,53 +0,0 @@
---
title: Billing
description: "Manage billing, payment methods, and customer details in ZITADEL Cloud."
---
## Billing
In the billing page shows your configured payment methods and the invoice
![](../../../../public/img/manuals/portal/customer_portal_billing.png)
### Payment Method
If you click on the "+" Button a popup will be shown with the needed fields to add a new payment method.
At the moment we provide only "Credit Card" payment
Once a payment method is configured, it can be selected directly in the instance creation process.
## Customer
To be able to create correct billings we will need some customer information from you.
This includes the following fields:
- Name
- Country
- Email address
- Address line 1
- Address line 2
- Postal Code
- City
## Tax ID
If available, enter your tax ID type and VAT number.
The tax ID will be shown on the invoice.
<Callout title="Reverse Charge">
When you enter the tax ID we will automatically calculate taxability.
Depending on your billing address we will mark the invoice as reverse charge.
</Callout>
## Update Billing Information
You will only need to add billing information if your want to get the pro tier. There are two options on how to add your billing info.
1. Go to the billing menu and add a new payment method.
2. Add the billing information directly during the upgrade process.
## Invoices
We show all you invoices, and you are able to download them directly in the Customer Portal.
![](../../../../public/img/manuals/portal/customer_portal_invoices.png)
@@ -1,98 +0,0 @@
---
title: ZITADEL Instances
description: "Create and manage multiple ZITADEL instances for dev, test, and production environments in the Customer Portal."
sidebar_label: Instances
---
The ZITADEL Customer Portal is used to manage all your different ZITADEL instances.
Instances are containers for your organizations, users and projects.
A recommended setup could look like the following:
1. Instance: "Dev Environment"
2. Instance: "Test Environment"
3. Instance: "Prod Environment"
In the free subscription model you have one instance included.
To be able to add more instances please upgrade to "ZITADEL Pro".
## Overview
The overview shows all the instances that are registered for your customer.
You can directly see the Custom Domain and data region.
With a click on an instance you get to the detail of the chosen instance.
![](../../../../public/img/manuals/portal/customer_portal_instance_overview.png)
## New instance
Click on the new button above the instance table to create a new instance.
1. Enter the name of your new instance
2. Add the credentials for your first administrator
- Username (prefilled)
- Password
3. Instance created! You can now see the details of your first instance.
<Callout type="info">
Every new instance gets a generated domain of the form [instancename][randomnumber].zitadel.cloud
</Callout>
![](../../../../public/img/manuals/portal/customer_portal_new_instance.gif)
## Detail
The detail shows you general information about your instance, the region and your usage.
![](../../../../public/img/manuals/portal/customer_portal_instance_detail.png)
### Upgrade to Pro
Your first instance is included in the free subscription.
As soon as you want to create your second instance or use a "pro" feature like choosing the region, you will have to upgrade to the Pro subscription.
To upgrade you must enter your billing information.
If you hit a limit from the free tier you will automatically be asked to add your credit card information and to subscribe to the pro tier.
You can also upgrade manually at any time.
1. Click the "Upgrade to PRO" button in the menu or go to the billing menu
2. If you choose the billing menu, you can now see your Free plan, click "Upgrade to Pro"
4. Add the missing data
- Payment method: Credit Card Information
- Customer: At least you have to fill the country
5. Save the information
![](../../../../public/img/manuals/portal/customer_portal_upgrade_tier.png)
### Add Custom Domain
We recommend register a Custom Domain to access your ZITADEL instance.
The primary Custom Domain of your ZITADEL instance will be the issuer of the instance. All other Custom Domains can be used to access the instance itself
1. Browse to the "Instances" Tab
2. Click on the instance box to open the instance detailed view
3. Scroll down to the **Custom Domains** section
4. Click on **+ Add Custom Domain**
5. Enter the domain you want
6. In the next screen you will get all the information you will have to add to your DNS provider to verify your domain
![Select Instance](../../../../public/img/manuals/portal/customer_portal_select_instance.png)
![Add Custom Domain](../../../../public/img/manuals/portal/customer_portal_add_domain.png)
Be aware that it has some impacts if you change the primary domain of your instance.
1. The urls and issuer have to change in your app
2. Passkey authentication is based on the domain, if you change it, your users will not be able to login with the registered passkey authentication
#### Verify Custom Domain
As soon as you have added your Custom Domain you will have to verify it, by adding a CNAME record to your DNS provider.
![Verify Custom Domain](../../../../public/img/manuals/portal/customer_portal_verify_domain.png)
1. Go to your DNS provider
2. Add a new CNAME record (You can find the target on the detail page of your instance)
3. After adding the CNAME you need to wait till the domain is verified (this can take some time)
> **_Please note:_** Do not delete the verification code, as ZITADEL Customer Portal will re-check the ownership of your domain periodically.
You will now be able to use the added Custom Domain to access your ZITADEL instance
@@ -11,9 +11,9 @@ import { Card, Cards } from 'fumadocs-ui/components/card';
import { FileText, Folder, Link as LinkIcon } from 'lucide-react';
<Cards>
<Card title="Start" href="/guides/manage/cloud/start" icon={<FileText />} />
<Card title="Instances" href="/guides/manage/cloud/instances" icon={<FileText />} />
<Card title="Settings" href="/guides/manage/cloud/settings" icon={<FileText />} />
<Card title="Billing" href="/guides/manage/cloud/billing" icon={<FileText />} />
<Card title="Users" href="/guides/manage/cloud/users" icon={<FileText />} />
<Card title="Start" href="https://help.zitadel.com/customer-portal-getting-started" icon={<FileText />} external />
<Card title="Instances" href="https://help.zitadel.com/customer-portal-instances" icon={<FileText />} external />
<Card title="Settings" href="https://help.zitadel.com/customer-portal-settings" icon={<FileText />} external />
<Card title="Billing" href="https://help.zitadel.com/customer-portal-billing" icon={<FileText />} external />
<Card title="Users" href="https://help.zitadel.com/customer-portal-administrators" icon={<FileText />} external />
</Cards>
@@ -1,45 +0,0 @@
---
title: Settings
description: "Configure your ZITADEL Cloud team settings, including notification preferences and subscriptions."
---
In the settings you can change your team name, notification settings and delete your team.
![](../../../../public/img/manuals/portal/customer_portal_settings_general.png)
## Team name
Change your ZITADEL Cloud team name by entering a new name.
Confirm the changes by clicking the Change button.
## Notifications and newsletters
You can subscribe and unsubscribe to notifications and newsletters:
- Onboarding: Welcome information for new users
- Newsletter: Regular newsletter on ZITADEL
- Product News: Receive product updates
- Security: Receive notifications related to security issues
<Callout title="Technical Advisories">
If you want to stay up to date on our technical advisories, we recommend [subscribing here to the mailing list](/support/technical_advisory#subscribe-to-our-mailing-list).
Technical advisories are notices that report major issues with ZITADEL Self-Hosted or the ZITADEL Cloud platform that could potentially impact security or stability in production environments.
</Callout>
You can also manage your subscriptions by clicking the unsubscribe link in the emails.
<Callout title="Mandatory Information">
We are required to inform you about changes to the terms of service or based on regulatory requirements. You can't unsubscribe to these notifications.
</Callout>
## Delete team
Permanently delete your ZITADEL Cloud account.
This will delete your team and delete all associated information.
Click **Delete Account** and confirm the next step.
## Update billing information
Refer to the [billing guide](./billing).
@@ -1,26 +0,0 @@
---
title: Getting Started with ZITADEL
description: "Create your ZITADEL Cloud account and first instance."
sidebar_label: Getting Started
---
If you are new to ZITADEL your first action is to create your first ZITADEL instance and an account to access the ZITADEL Customer Portal.
The ZITADEL customer Portal is used to manage all your different ZITADEL instances.
You can also manage your subscriptions, billing, newsletters and support requests.
Go to [ZITADEL Customer Portal](https://zitadel.com) and start by entering you email or use an existing account like Google.
![](../../../../public/img/manuals/portal/customer_portal_landing_page.png)
In a second step fill out your user data like First-, Last- and Team-name.
![](../../../../public/img/manuals/portal/customer_portal_landing_page_2.png)
If you did start with your email instead of a social login (e.g. Google) you have to fill a password for your authentication.
In that case you will get an initialization mail to verify your account.
![](../../../../public/img/manuals/portal/customer_portal_landing_page_3.png)
You are now registered with a free account and ready to try all the features of ZITADEL.
Sign in to [ZITADEL Customer Portal](https://zitadel.com), to manage all you instances.
@@ -1,32 +0,0 @@
---
title: Customer Portal Administrators
description: "Manage administrators who have access to the ZITADEL Customer Portal."
sidebar_label: Customer Portal Administrators
---
Manage all your administrators who are allowed to access the Customer Portal.
For the moment all users with access to the Customer Portal will have the role "Admin".
![](../../../../public/img/manuals/portal/customer_portal_administrator_list.png)
## Add new administrator
1. Go to the Administrators tab in the ZITADEL Customer Portal
2. Click the button "Create"
3. Fill in the Firstname, Lastname, Email and the username
4. Click create
The user will receive a verification email. Clicking the button in the email will lead to the user activation screen where password creation is required.
![](../../../../public/img/manuals/portal/customer_portal_add_admin.png)
## Delete administrator
1. Go to the Administrators tab in the ZITADEL Customer Portal
2. Click the bin icon in the users table for the user you like to delete
3. You will get a popup, where you have to enter the login name of the user to confirm that you like to delete the user
4. Click the "delete" button
The user will be deleted and has no access to the Customer Portal anymore
![](../../../../public/img/manuals/portal/customer_portal_delete_admin.png)
@@ -29,9 +29,7 @@ Before you start, make sure you have everything set up correctly.
4. Paste this snippet into the multiline text-field.
5. Replace the snippets placeholders and select **Save**.
```js reference
https://github.com/zitadel/actions/blob/main/examples/add_user_grant.js
```
<GithubCodeBlock url="https://github.com/zitadel/actions/blob/main/examples/add_user_grant.js" />
## Run the action when a user registers
@@ -12,7 +12,7 @@ Domain discovery is typically used in [B2B](./b2b) or [SaaS](./saas) scenarios w
![](../../../public/img/guides/solution-scenarios/domain-discovery.png)
In the example there is a service provider with a ZITADEL instance running on a [Custom Domain](/guides/manage/cloud/instances#add-custom-domain) on `login.mycompany.com`.
In the example there is a service provider with a ZITADEL instance running on a [Custom Domain](https://help.zitadel.com/customer-portal-instances#add-custom-domain) on `login.mycompany.com`.
By default, all users login on the organization **CIAM** with their preferred social login provider.
Users of the two business customers **Alpha** and **Beta** should log in, according to their organization login and access policy settings.
@@ -4,59 +4,76 @@ description: "Guidelines and strategies for restricting user access to the ZITAD
sidebar_label: Limit access to management console
---
ZITADEL includes a management console that allows Administrators to configure all resources. All uses, including end-users, by default, view and manage their profile information.
In some use cases you want to prevent users from accessing the management console, this could be:
- User management is integrated into your own app
- Users login via SSO and should not be able to change their data in the Management Console
- Only administrators should be able to access Management Console to manage their users
ZITADEL includes a Management Console that allows administrators to configure all resources. By default, all users, including end users, can view and manage their profile information.
In some cases, you may want to prevent users from accessing the Management Console. Common reasons include:
- User management is integrated into your own application.
- Users log in via SSO and should not be able to change their data in the Management Console.
- Only administrators should be able to access the Management Console to manage users.
At the moment it is not possible to simply disable the Management Console, but with the settings described in this guide, you can prevent users from accessing the Console.
While it is not currently possible to completely disable the Management Console, you can prevent users from accessing it by following the strategies described in this guide.
## Self-hosted
If you are running ZITADEL self-hosted, we recommend restricting the access to ZITADEL Management Console via WAF/reverse-proxy for Non-Administrator users
If you are running a self-hosted instance of ZITADEL, we recommend restricting access to the Management Console via a Web Application Firewall (WAF) or reverse proxy for non-administrator users.
## ZITADEL Cloud (and self-hosted)
### Default redirect url
### Default redirect URI
One goal is to never send the end user to the ZITADEL management console.
This does make sense if you build your own user profile page within your application.
In that case you probably want to redirect the user to your own application, instead of to the console.
One goal is to never send the end user to the ZITADEL Management Console.
This is particularly useful if you have built your own user profile page within your application.
In such cases, you likely want to redirect users to your own application instead of the console.
Read more about how to set the default redirect URI: [Settings - Default Redirect URI](/guides/manage/console/default-settings#default-redirect-uri)
<PreventLockout />
### Restricting Management Console in default-project
With this workaround, you will limit users from accessing the [default-project](/guides/manage/console/projects-overview#the-default-project) if they are not explicitly granted to use this project.
When setting the "Check for Project on Authentication" setting on a project, only users of organizations with a grant to that project can access it.
With this workaround, you will limit users from accessing the [default-project](/guides/manage/console/projects-overview#the-default-project) if they are not explicitly granted access to it.
When enabling the "**Authentication is restricted to users from organizations that have been granted access to this project**" setting on a project, only users from organizations with a Project Grant for that project can access it.
By default, this setting is disabled, so all users can access the project.
<Callout type="info">
Before a user can be authenticated, it is verified that their affiliated organization has been granted access to this project. Authentication is not permitted for users from unauthorized organizations.
</Callout>
![](../../../public/img/guides/solution-scenarios/console-check-project-auth.png)
Start by granting the organization of your administrators that should be able to access the Management Console, the [default project](/guides/manage/console/projects-overview#the-default-project):
Start by granting the organization of your administrators access to the ZITADEL project (default project):
<Steps>
1. Click on the default-project > Grants > New +
![](../../../public/img/guides/solution-scenarios/console-zitadel-project-grants.png)
<Step>
1. Go to the **ZITADEL** project, select **Project Grants**, and click **New**.
![](../../../public/img/guides/solution-scenarios/console-zitadel-project-grants.png)
</Step>
2. On the next screen select the organization to which you want to give the grant
![](../../../public/img/guides/solution-scenarios/console-create-project-grant-org.png)
<Step>
Select the organization you want to grant access to.
![](../../../public/img/guides/solution-scenarios/console-create-project-grant-org.png)
</Step>
3. You can skip the screen with the role selection and click save
4. Make sure the grant is in now in the overview and marked as active.
<Step>
You can skip the role selection and click **Save**.
</Step>
To avoid accidental lock-out, the [default project](/guides/manage/console/projects-overview#the-default-project) hides the checkbox "Check Project on Authentication" in the Management Console:
<Step>
Ensure the grant appears in the overview and is marked as **Active**.
</Step>
</Steps>
To avoid accidental lockout, the [default project](/guides/manage/console/projects-overview#the-default-project) hides the "**Authentication is restricted to users from organizations that have been granted access to this project**" checkbox in the Management Console:
![Default Project](../../../public/img/guides/solution-scenarios/console-default-project.png)
You need to use the [update project API call](/reference/api/management/zitadel.management.v1.ManagementService.UpdateProject) to set the settings on the project.
You need to use the [Update Project API](/reference/api/management/zitadel.management.v1.ManagementService.UpdateProject) to configure this setting on the project.
First, you need a user with administrator permissions to change the project settings in that organization.
This means either you add the manager to the organization or you use an administrator on the instance level with "IAM-OWNER" permissions.
After that create a Personal Access Token (PAT) for the administrator.
This means either you add the Administrator to the organization or you use an administrator on the instance level with `IAM-OWNER` permissions.
After that, create a Personal Access Token (PAT) for the administrator.
More detailed information about creating a PAT and administrator roles you can find [here](/guides/integrate/service-accounts/personal-access-token)
More detailed information about creating a PAT and Administrator Roles can be found [here](/guides/integrate/service-accounts/personal-access-token).
Then you have to send the following request:
Then, send the following request:
```bash
curl -L -X PUT "https://${CUSTOM_DOMAIN}/management/v1/projects/${PROJECT_ID}" \
-H 'Content-Type: application/json' \
@@ -71,12 +88,17 @@ curl -L -X PUT "https://${CUSTOM_DOMAIN}/management/v1/projects/${PROJECT_ID}" \
}'
```
Where
- `${CUSTOM_DOMAIN}` is your Custom Domain, f.e. mydemo.zitadel.cloud
- `${PAT}` is your service account's personal access token
- `${PROJECT_ID}` is the default-project's ID that can be found in Management Console see screenshot above
Where:
- `${CUSTOM_DOMAIN}` is your custom domain (e.g., `mydemo.zitadel.cloud`).
- `${PAT}` is the administrator's Personal Access Token.
- `${PROJECT_ID}` is the default project's ID, which can be found in the Management Console (see screenshot above).
You should now be able to log in with users of the organization that have the grant (in this example users of the organization "Customer-A").
<Callout type="info">
You can also use the [V2 Update Project API](/reference/api/project/zitadel.project.v2.ProjectService.UpdateProject) to perform this action.
</Callout>
You should now be able to log in with users of the organization that have the Project Grant (in this example, users of the organization "Customer-A").
All other users should see the following error message after authentication:
![](../../../public/img/guides/solution-scenarios/console-access-restricted.png)
@@ -511,8 +511,8 @@ ZITADEL handles the complexity of the OIDC handshake so your app stays secure wi
</Tab>
<Tab value="Vue.js">
```bash
git clone https://github.com/zitadel/example-auth-vuejs.git
cd example-auth-vuejs
git clone https://github.com/zitadel/example-auth-vue.git
cd example-auth-vue
```
</Tab>
</FrameworkSelector>
@@ -1072,12 +1072,7 @@ ZITADEL handles the complexity of the OIDC handshake so your app stays secure wi
## Success! 🚀
You’ve successfully integrated ZITADEL into an application.
<Callout type="warn" title="Prevent Settings Misconfiguration Lockouts">
Login policy settings misconfigurations that occur during the testing phase can easily lead to a [lockout](/legal/policies/account-lockout-policy). To ensure you don't lose access to your instance:
1. **Generate a backup PAT:** Create a [Service Account Personal Access Token](/guides/integrate/service-accounts/personal-access-token) with the [`IAM_OWNER`](/guides/manage/console/administrators) role to revert any login UI misconfigurations using the API.
2. **Add a second Instance Administrator:** Always designate at least one [**second instance administrator**](/guides/manage/console/administrators).
</Callout>
<PreventLockout />
### What’s next?
- [**Example Applications**](/examples/introduction): Find more comprehensive guides and examples for the different frameworks
+2 -2
View File
@@ -53,10 +53,10 @@ You can use ZITADEL in two ways:
- **ZITADEL Cloud**: This is our public cloud service. Use the free tier to start in minutes.
- **Self-hosted ZITADEL**: For full control, deploy ZITADEL wherever you prefer.
If you're unsure, consider the generous free tier of [ZITADEL Cloud](/guides/manage/cloud/start).
If you're unsure, consider the generous free tier of [ZITADEL Cloud](https://help.zitadel.com/customer-portal-getting-started).
<Cards>
<Card title="ZITADEL Cloud" href="/guides/manage/cloud/start" icon={<Server />}>
<Card title="ZITADEL Cloud" href="https://help.zitadel.com/customer-portal-getting-started" icon={<Server />} external >
Choose if you want:
- A turnkey solution that's ready to go
- Global scalability without the hassle
+3 -3
View File
@@ -5,7 +5,7 @@ sidebar_label: Vue.js
framework_url: https://vuejs.org
auth_library: oidc-client-ts
auth_library_url: https://www.npmjs.com/package/oidc-client-ts
example_repo: https://github.com/zitadel/example-auth-vuejs
example_repo: https://github.com/zitadel/example-auth-vue
auth_flow: pkce
status: stable
---
@@ -48,7 +48,7 @@ Before running this example, you need to create and configure a PKCE application
Once you have your [Zitadel application configured](https://zitadel.com/docs/guides/integrate/login/oidc/login-users), follow these steps:
1. Clone the [repository](https://github.com/zitadel/example-auth-vuejs).
1. Clone the [repository](https://github.com/zitadel/example-auth-vue).
2. Create a `.env` file in the project root and configure it with the values from your [Zitadel application](https://zitadel.com/docs/guides/manage/console/applications-overview):
```
VITE_ZITADEL_DOMAIN=https://your-instance.zitadel.cloud
@@ -80,4 +80,4 @@ npm run dev
* [vue-oidc-context (wrapper)](https://www.npmjs.com/package/vue-oidc-context)
* [oidc-client-ts (primary library)](https://www.npmjs.com/package/oidc-client-ts)
* [oidc-client-ts GitHub](https://github.com/authts/oidc-client-ts)
* [Example repository](https://github.com/zitadel/example-auth-vuejs)
* [Example repository](https://github.com/zitadel/example-auth-vue)
@@ -8,12 +8,12 @@ This guide takes you from zero to a running ZITADEL instance on Kubernetes and t
## Stage 1 — Quickstart
The ZITADEL chart ships with an optional PostgreSQL subchart so a single `helm install` deploys the database alongside ZITADEL. You still need an ingress controller running in your cluster.
The ZITADEL chart ships with an optional PostgreSQL subchart so a single `helm install` deploys the database alongside ZITADEL. You still need a routing controller running in your cluster to expose the service.
### Prerequisites
- A Kubernetes cluster (1.30+)
- An ingress controller (e.g. Traefik, NGINX)
- An Ingress controller (e.g., Traefik, NGINX) **OR** a Gateway API controller
- [kubectl](https://kubernetes.io/docs/tasks/tools/)
- [Helm](https://helm.sh/docs/intro/install/) 3.x or 4.x
@@ -31,7 +31,7 @@ mkdir zitadel-helm && cd zitadel-helm &&
curl -fsSLO https://raw.githubusercontent.com/zitadel/zitadel-charts/main/examples/0-quickstart/quickstart-values.yaml
```
Edit `quickstart-values.yaml` before installing. For a local k3d or k3s cluster, these values usually work as-is:
Edit `quickstart-values.yaml` before installing. For a local k3d or k3s cluster, these standard Ingress values usually work as-is:
```yaml
zitadel:
@@ -47,7 +47,7 @@ login:
className: traefik
```
If you are using a different ingress controller, replace both `className` values with that controller's IngressClass name.
If you are using a different ingress controller, replace both `className` values with that controller's IngressClass name.
```bash
helm repo add zitadel https://charts.zitadel.com &&
@@ -66,8 +66,8 @@ Once ZITADEL has been initialized with a masterkey, it **cannot be changed** wit
</Callout>
<Callout type="info">
The stack runs: your ingress controller → **ZITADEL API** (Go) + **ZITADEL Login** (Next.js) → **PostgreSQL** (bundled).
See [HTTP/2](/self-hosting/manage/http2) for requirements on how the ingress controller must forward traffic to ZITADEL pods.
The stack runs: your ingress/gateway controller → **ZITADEL API** (Go) + **ZITADEL Login** (Next.js) → **PostgreSQL** (bundled).
See [HTTP/2](/self-hosting/manage/http2) for requirements on how traffic must be forwarded to ZITADEL pods via h2c.
</Callout>
### Swap out components
@@ -76,16 +76,16 @@ The bundled PostgreSQL subchart is for quickstart use only. You can replace it i
| Component | How to replace |
|-----------|---------------|
| **Database** | Set `postgresql.enabled: false` and configure `ZITADEL_DATABASE_POSTGRES_DSN` pointing to your own PostgreSQL. Use the `postgres` maintenance database so ZITADEL can create its own database during initialization |
| **Ingress controller** | Update `ingress.className` (and `login.ingress.className`) to match your controller's IngressClass |
| **Caching** | Add Redis by configuring `zitadel.configmapConfig.Caches` (see [Caching](/self-hosting/deploy/kubernetes/caching)) |
| **Database** | Set `postgresql.enabled: false` and configure `ZITADEL_DATABASE_POSTGRES_DSN` pointing to your own PostgreSQL. Use the `postgres` maintenance database so ZITADEL can create its own database during initialization. |
| **Routing / Ingress** | Update `ingress.className` to match your controller, or disable the Helm ingress (`ingress.enabled: false`) to manage your own Gateway API `HTTPRoute` resources. |
| **Caching** | Add Redis by configuring `zitadel.configmapConfig.Caches` (see [Caching](/self-hosting/deploy/kubernetes/caching)). |
## Stage 2 — Production Cluster
For production you need:
- A Kubernetes cluster (1.30+)
- An ingress controller (Traefik, NGINX, or cloud-native)
- A routing solution: Ingress controller (Traefik, NGINX) or a Gateway API implementation
- A domain with DNS configured
- TLS certificates (via cert-manager, ACME, or manually)
- PostgreSQL 14+ (managed service or in-cluster)
@@ -96,7 +96,7 @@ For production you need:
</Callout>
<Callout type="warn">
The ZITADEL management console [requires end-to-end HTTP/2 support](/self-hosting/manage/http2). Ensure your ingress controller is configured to forward HTTP/2 (h2c) traffic to ZITADEL pods.
The ZITADEL management console [requires end-to-end HTTP/2 support](/self-hosting/manage/http2). Ensure your routing controller is configured to forward HTTP/2 (h2c) traffic to ZITADEL pods.
</Callout>
### Create secrets
@@ -113,7 +113,7 @@ kubectl create secret generic zitadel-db-credentials \
### Configure values
Save the following as `values.yaml`. Replace `zitadel.example.com` with your domain:
Save the base configuration below as `values.yaml`. Replace `zitadel.example.com` with your domain:
```yaml
replicaCount: 2
@@ -140,6 +140,20 @@ zitadel:
Password: "YourSecurePassword123!"
PasswordChangeRequired: true
podDisruptionBudget:
enabled: true
minAvailable: 1
```
### Exposing ZITADEL (Choose your routing method)
ZITADEL can be exposed using standard Kubernetes **Ingress** or the newer **Gateway API**. Choose the method that matches your cluster's architecture.
#### Option A: Using Standard Ingress (e.g., Traefik)
Append the following routing block to your `values.yaml` to have Helm automatically generate standard `Ingress` objects:
```yaml
ingress:
enabled: true
className: traefik
@@ -174,12 +188,44 @@ login:
- secretName: zitadel-tls
hosts:
- zitadel.example.com
podDisruptionBudget:
enabled: true
minAvailable: 1
```
#### Option B: Using Gateway API
If your cluster utilizes the Kubernetes Gateway API, disable the Helm chart's built-in ingress generation by ensuring both `ingress.enabled: false` and `login.ingress.enabled: false` are set (which is the default).
After deploying the Helm chart, apply an `HTTPRoute` resource to route traffic to the ZITADEL services via your `Gateway`:
```yaml
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: zitadel-route
namespace: zitadel
spec:
parentRefs:
- name: your-cluster-gateway
namespace: gateway-system
hostnames:
- "zitadel.example.com"
rules:
- matches:
- path:
type: PathPrefix
value: /ui/v2/login
backendRefs:
- name: zitadel-login
port: 8080
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: zitadel
port: 8080
```
*(Note: Ensure your Gateway controller supports h2c/HTTP2 backend connections).*
### Install
```bash
@@ -213,9 +259,9 @@ Review the [Production Checklist](/self-hosting/manage/productionchecklist) befo
| Guide | Description |
|-------|-------------|
| [Configuration](/self-hosting/deploy/kubernetes/configuration) | All configmap and secret options, autoscaling, security contexts |
| [Ingress](/self-hosting/deploy/kubernetes/ingress) | Traefik, NGINX, and cloud-native ingress setup |
| [Routing](/self-hosting/deploy/kubernetes/ingress) | Ingress, Gateway API, and cloud-native setup |
| [Database](/self-hosting/deploy/kubernetes/database) | PostgreSQL TLS modes, credentials, and connection pooling |
| [Operations](/self-hosting/deploy/kubernetes/operations) | Upgrades, manual and automatic scaling, resource limits |
| [Caching](/self-hosting/deploy/kubernetes/caching) | Redis/Valkey caching configuration |
| [Observability](/self-hosting/deploy/kubernetes/observability) | Traces (OTLP), Prometheus metrics, and log collection |
| [Uninstalling](/self-hosting/deploy/kubernetes/uninstalling) | Remove ZITADEL from your cluster |
| [Uninstalling](/self-hosting/deploy/kubernetes/uninstalling) | Remove ZITADEL from your cluster |
@@ -181,7 +181,7 @@ Caches:
All HTTP and gRPC requests sent to ZITADEL receive an instance context. The instance is usually resolved by the domain from the request. In some cases, like the [system service](/reference/api/system), the instance can be resolved by its ID. An instance object contains many of the [default settings](/reference/api/system), the instance can be resolved by its ID. An instance object contains many of the [default settings](/guides/manage/console/default-settings):
- Instance [features](/guides/manage/console/default-settings#features)
- Custom Domains: generated and [custom](/guides/manage/cloud/instances#add-custom-domain)
- Custom Domains: generated and [custom](https://help.zitadel.com/customer-portal-instances#add-custom-domain)
- [Trusted domains](/apis/resources/admin/admin-service-add-instance-trusted-domain)
- Security settings ([IFrame policy](/guides/solution-scenarios/configurations#embedding-zitadel-in-an-iframe))
- Limits[^2]
-19
View File
@@ -690,25 +690,6 @@ export const guidesSidebar: readonly SidebarItem[] = [
items: [
"guides/manage/console/console-overview",
"guides/manage/cloud/egress",
{
type: "category",
label: "Customer Portal",
collapsed: true,
link: {
type: "generated-index",
title: "Overview",
slug: "guides/manage/cloud/overview",
description:
"Our customer portal is used to manage all your ZITADEL instances. You can also manage your subscriptions, billing, newsletters and support requests.",
},
items: [
"guides/manage/cloud/start",
"guides/manage/cloud/instances",
"guides/manage/cloud/settings",
"guides/manage/cloud/billing",
"guides/manage/cloud/users",
],
},
{
type: "category",
label: "Self-Hosted",
+19 -4
View File
@@ -1,7 +1,9 @@
import { docs, versions } from '../.source/server';
import { type InferPageType, loader } from 'fumadocs-core/source';
import { loader } from 'fumadocs-core/source';
import { lucideIconsPlugin } from 'fumadocs-core/source/lucide-icons';
const DOCS_BASE_PATH = '/docs';
// See https://fumadocs.dev/docs/headless/source-api for more info
export const source = loader({
baseUrl: '/',
@@ -15,6 +17,11 @@ export const versionSource = loader({
plugins: [lucideIconsPlugin()],
});
type LatestPage = ReturnType<typeof source.getPages>[number];
export type DocPage =
| LatestPage
| ReturnType<typeof versionSource.getPages>[number];
export function getPage(slugs: string[] | undefined) {
const safeSlugs = slugs || [];
// If the first slug matches a known version pattern (e.g., starts with 'v' and is in our list), use versionSource
@@ -27,16 +34,24 @@ export function getPage(slugs: string[] | undefined) {
return { page: source.getPage(safeSlugs), source: source };
}
export function getPageImage(page: InferPageType<typeof source>) {
export function getAllDocPages(): DocPage[] {
return [...source.getPages(), ...versionSource.getPages()];
}
export function generateAllDocParams() {
return [...source.generateParams(), ...versionSource.generateParams()];
}
export function getPageImage(page: DocPage) {
const segments = [...page.slugs, 'image.png'];
return {
segments,
url: `/og/docs/${segments.join('/')}`,
url: `${DOCS_BASE_PATH}/og/docs/${segments.join('/')}`,
};
}
export async function getLLMText(page: InferPageType<typeof source>) {
export async function getLLMText(page: LatestPage) {
const processed = await page.data.getText('processed');
return `# ${page.data.title}
+4
View File
@@ -6,6 +6,8 @@ import { Callout } from 'fumadocs-ui/components/callout';
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
import { Step, Steps } from 'fumadocs-ui/components/steps';
import Admonition from '@/components/docusaurus/admonition';
import { GithubCodeBlock } from '@/components/github-code-block';
import { PreventLockout } from '@/components/prevent-lockout';
export function useMDXComponents(components?: MDXComponents): MDXComponents {
return {
@@ -18,6 +20,8 @@ export function useMDXComponents(components?: MDXComponents): MDXComponents {
Steps,
APIPage,
TerminologyUpdate,
GithubCodeBlock,
PreventLockout,
...components,
};
}
+25 -25
View File
@@ -7,8 +7,8 @@
"generate:index-pages": "tsx scripts/generate-index-pages.ts",
"fetch:remote-content": "node scripts/fetch-remote-content.mjs",
"generate:proto-docs": "node scripts/generate-proto-docs.mjs",
"generate:api-reference": "tsx scripts/generate-api-reference.ts --only-generate",
"fix:api-links": "tsx scripts/generate-api-reference.ts --only-fix",
"generate:api-reference": "node scripts/generate-api-reference.ts --only-generate",
"fix:api-links": "node scripts/generate-api-reference.ts --only-fix",
"build:mdx-source": "fumadocs-mdx",
"build": "next build",
"dev": "next dev",
@@ -22,55 +22,55 @@
"lint": "eslint ."
},
"dependencies": {
"@headlessui/react": "^2.2.9",
"@headlessui/react": "^2.2.10",
"@heroicons/react": "^2.2.0",
"@inkeep/cxkit-react": "^0.5.116",
"@scalar/api-reference": "^1.49.8",
"@inkeep/cxkit-react": "^0.5.117",
"@scalar/api-reference": "^1.55.3",
"@shikijs/rehype": "^4.0.2",
"@shikijs/types": "^4.0.2",
"@types/js-yaml": "^4.0.9",
"clsx": "^2.1.1",
"fumadocs-core": "16.7.9",
"fumadocs-mdx": "14.2.11",
"fumadocs-openapi": "10.6.3",
"fumadocs-ui": "16.7.9",
"fumadocs-core": "16.8.10",
"fumadocs-mdx": "14.3.2",
"fumadocs-openapi": "10.8.2",
"fumadocs-ui": "16.8.10",
"js-yaml": "^4.1.1",
"lucide-react": "^0.577.0",
"mixpanel-browser": "^2.77.0",
"next": "16.2.2",
"mixpanel-browser": "^2.78.0",
"next": "16.2.6",
"next-themes": "^0.4.6",
"react": "19.2.4",
"react": "19.2.6",
"react-copy-to-clipboard": "^5.1.1",
"react-dom": "19.2.4",
"react-dom": "19.2.6",
"react-google-charts": "^5.2.1",
"shiki": "^4.0.2",
"tailwind-merge": "^3.5.0",
"tailwind-merge": "^3.6.0",
"vanilla-cookieconsent": "^3.1.0",
"zod": "^4.3.6"
"zod": "^4.4.3"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tailwindcss/postcss": "^4.2.2",
"@tailwindcss/postcss": "^4.3.0",
"@tailwindcss/typography": "^0.5.19",
"@types/mdx": "^2.0.13",
"@types/node": "^25.5.0",
"@types/node": "^25.7.0",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@typescript-eslint/eslint-plugin": "^8.58.0",
"@typescript-eslint/parser": "^8.58.0",
"eslint": "^10.1.0",
"@typescript-eslint/eslint-plugin": "^8.59.3",
"@typescript-eslint/parser": "^8.59.3",
"eslint": "^10.3.0",
"eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-mdx": "^3.7.0",
"glob": "^13.0.6",
"globals": "^17.4.0",
"next-validate-link": "^1.6.4",
"postcss": "^8.5.8",
"globals": "^17.6.0",
"next-validate-link": "^1.6.6",
"postcss": "^8.5.14",
"raw-loader": "^4.0.2",
"remark-heading-id": "^1.0.1",
"semver": "^7.7.4",
"tailwindcss": "^4.2.2",
"semver": "^7.8.0",
"tailwindcss": "^4.3.0",
"tailwindcss-animate": "^1.0.7",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 62 KiB

-3
View File
@@ -39,9 +39,6 @@ export const versions = defineDocs({
sidebar_label: z.string().optional(),
}),
files: ['v*/**/*.md', 'v*/**/*.mdx', '!**/_*'], // Include only versioned folders from content
postprocess: {
includeProcessedMarkdown: true,
},
},
meta: {
schema: metaSchema,
+7
View File
@@ -96,6 +96,13 @@ declare namespace NodeJS {
*/
API_CACHE_CONFIG?: string;
/**
* Optional: Disable the proxy's runtime CSP fetch from the ZITADEL API.
* Defaults to true (enabled). Set to "false" to skip fetching iframe origins
* and use a restrictive default CSP instead.
*/
CSP_FETCH_ENABLED?: string;
/**
* Optional: Disable OpenTelemetry instrumentation.
* Set to "true" to bypass OTEL initialization.
-1
View File
@@ -30,7 +30,6 @@ const nextConfig = {
experimental: {
// Add React 19 compatibility optimizations
optimizePackageImports: ["@radix-ui/react-tooltip", "@heroicons/react"],
useCache: true,
serverActions: {
...(process.env.SERVER_ACTION_ALLOWED_ORIGINS
? { allowedOrigins: process.env.SERVER_ACTION_ALLOWED_ORIGINS.split(",").map((o) => o.trim()) }
+37 -37
View File
@@ -25,60 +25,60 @@
"dependencies": {
"@connectrpc/connect": "^2.1.1",
"@connectrpc/connect-node": "^2.1.1",
"@headlessui/react": "^2.2.9",
"@headlessui/react": "^2.2.10",
"@heroicons/react": "^2.2.0",
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/api-logs": "^0.214.0",
"@opentelemetry/auto-instrumentations-node": "^0.72.0",
"@opentelemetry/resource-detector-container": "^0.8.5",
"@opentelemetry/resource-detector-gcp": "^0.49.0",
"@opentelemetry/resources": "^2.6.1",
"@opentelemetry/sdk-node": "^0.214.0",
"@opentelemetry/winston-transport": "^0.24.0",
"@opentelemetry/api-logs": "^0.218.0",
"@opentelemetry/auto-instrumentations-node": "^0.76.0",
"@opentelemetry/resource-detector-container": "^0.8.8",
"@opentelemetry/resource-detector-gcp": "^0.53.0",
"@opentelemetry/resources": "^2.7.1",
"@opentelemetry/sdk-node": "^0.218.0",
"@opentelemetry/winston-transport": "^0.28.0",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/forms": "^0.5.11",
"clsx": "^2.1.1",
"copy-to-clipboard": "^3.3.3",
"deepmerge": "^4.3.1",
"escape-html": "^1.0.3",
"lru-cache": "^11.3.2",
"lru-cache": "^11.3.6",
"lucide-react": "^0.577.0",
"moment": "^2.30.1",
"next": "16.2.2",
"next-intl": "^4.9.0",
"next": "16.2.6",
"next-intl": "^4.11.2",
"next-themes": "^0.4.6",
"nice-grpc": "^2.1.14",
"nice-grpc": "^2.1.16",
"qrcode.react": "^4.2.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-hook-form": "^7.72.0",
"react": "19.2.6",
"react-dom": "19.2.6",
"react-hook-form": "^7.75.0",
"server-only": "^0.0.1",
"tinycolor2": "^1.6.0",
"uuid": "^13.0.0",
"uuid": "^14.0.0",
"winston": "^3.19.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@faker-js/faker": "^9.9.0",
"@next/eslint-plugin-next": "16.2.2",
"@opentelemetry/sdk-trace-base": "^2.6.1",
"@opentelemetry/sdk-trace-node": "^2.6.1",
"@next/eslint-plugin-next": "16.2.6",
"@opentelemetry/sdk-trace-base": "^2.7.1",
"@opentelemetry/sdk-trace-node": "^2.7.1",
"@otplib/core": "^12.0.1",
"@otplib/plugin-crypto": "^12.0.1",
"@otplib/plugin-thirty-two": "^12.0.1",
"@playwright/test": "^1.59.1",
"@tailwindcss/postcss": "^4.2.2",
"@playwright/test": "^1.60.0",
"@tailwindcss/postcss": "^4.3.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/escape-html": "^1.0.4",
"@types/node": "^25.5.0",
"@types/node": "^25.7.0",
"@types/node-forge": "^1.3.14",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@types/tinycolor2": "^1.4.6",
"@types/uuid": "^11.0.0",
"@typescript-eslint/eslint-plugin": "^8.58.0",
"@typescript-eslint/parser": "^8.58.0",
"@typescript-eslint/eslint-plugin": "^8.59.3",
"@typescript-eslint/parser": "^8.59.3",
"@vercel/git-hooks": "^1.0.0",
"@vitejs/plugin-react": "^4.7.0",
"@zitadel/client": "workspace:*",
@@ -86,31 +86,31 @@
"concurrently": "^9.2.1",
"dotenv-cli": "^11.0.0",
"env-cmd": "^11.0.0",
"esbuild": "^0.27.5",
"eslint": "^10.1.0",
"esbuild": "^0.28.0",
"eslint": "^10.3.0",
"eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-cypress": "^4.3.0",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-hooks": "^7.1.1",
"gaxios": "^7.1.4",
"globals": "^17.4.0",
"globals": "^17.6.0",
"grpc-tools": "^1.13.1",
"jsdom": "^26.1.0",
"lint-staged": "^16.4.0",
"node-forge": "^1.4.0",
"nodemon": "^3.1.14",
"postcss": "^8.5.8",
"prettier": "^3.8.1",
"postcss": "^8.5.14",
"prettier": "^3.8.3",
"prettier-plugin-organize-imports": "^4.3.0",
"prettier-plugin-tailwindcss": "^0.7.2",
"sass": "^1.98.0",
"start-server-and-test": "^3.0.0",
"tailwindcss": "^4.2.2",
"testcontainers": "^11.13.0",
"ts-proto": "^2.11.6",
"prettier-plugin-tailwindcss": "^0.8.0",
"sass": "^1.99.0",
"start-server-and-test": "^3.0.4",
"tailwindcss": "^4.3.0",
"testcontainers": "^11.14.0",
"ts-proto": "^2.11.8",
"typescript": "^5.9.3",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^4.1.2"
"vitest": "^4.1.6"
}
}
@@ -16,9 +16,11 @@ import {
} from "@/lib/zitadel";
import { Timestamp, timestampDate } from "@zitadel/client";
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
import { SecondFactorType } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
import { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("mfa");
@@ -111,6 +113,41 @@ export default async function Page(props: { searchParams: Promise<Record<string
const { valid } = sessionWithData ? isSessionValid(sessionWithData) : { valid: false };
if (force === "true" && valid && sessionWithData?.factors?.user?.loginName && loginSettings) {
const emailVerified = sessionWithData.emailVerified ?? false;
const phoneVerified = sessionWithData.phoneVerified ?? false;
const hasVisibleFactor = loginSettings.secondFactors.some((f) => {
switch (f) {
case SecondFactorType.OTP:
case SecondFactorType.U2F:
return true;
case SecondFactorType.OTP_EMAIL:
return emailVerified;
case SecondFactorType.OTP_SMS:
return phoneVerified;
default:
return false;
}
});
if (!hasVisibleFactor && !emailVerified && loginSettings.secondFactors.includes(SecondFactorType.OTP_EMAIL)) {
const verifyParams = new URLSearchParams({
loginName: sessionWithData.factors.user.loginName,
send: "true",
});
const org = organization ?? sessionWithData.factors.user.organizationId;
if (requestId) {
verifyParams.set("requestId", requestId);
}
if (org) {
verifyParams.set("organization", org as string);
}
redirect(`/verify?${verifyParams}`);
}
}
return (
<DynamicTheme branding={branding}>
<div className="flex flex-col space-y-4">
+25 -21
View File
@@ -1,17 +1,19 @@
import { isRSCRequest, validateAuthRequest } from "@/lib/auth-utils";
import { getAllSessions } from "@/lib/cookies";
import { isClassifiedError } from "@/lib/grpc/interceptors/error-classification";
import { createLogger } from "@/lib/logger";
import { FlowInitiationParams, handleOIDCFlowInitiation, handleSAMLFlowInitiation } from "@/lib/server/flow-initiation";
import { getServiceConfig } from "@/lib/service-url";
import { listSessions, ServiceConfig } from "@/lib/zitadel";
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
import { headers } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
export const dynamic = "force-dynamic";
export const revalidate = false;
export const fetchCache = "default-no-store";
const logger = createLogger("login-route");
async function loadSessions({ serviceConfig, ids }: { serviceConfig: ServiceConfig; ids: string[] }): Promise<Session[]> {
const response = await listSessions({ serviceConfig, ids: ids.filter((id: string | undefined) => !!id) });
@@ -19,8 +21,7 @@ async function loadSessions({ serviceConfig, ids }: { serviceConfig: ServiceConf
}
export async function GET(request: NextRequest) {
const _headers = await headers();
const { serviceConfig } = getServiceConfig(_headers);
const { serviceConfig } = getServiceConfig(request.headers);
const searchParams = request.nextUrl.searchParams;
@@ -39,30 +40,33 @@ export async function GET(request: NextRequest) {
const ids = sessionCookies.map((s) => s.id);
let sessions: Session[] = [];
if (ids && ids.length) {
sessions = await loadSessions({ serviceConfig, ids });
try {
sessions = await loadSessions({ serviceConfig, ids });
} catch (error) {
logger.warn("Failed to load sessions", { error });
// listSessions can fail for various reasons (stale/expired session IDs
// still in cookies, API errors, etc.). Treat any failure as "no valid
// sessions" so the user is redirected to loginname instead of a 500.
sessions = [];
}
}
// Flow initiation - delegate to appropriate handler
const flowParams: FlowInitiationParams = { serviceConfig, requestId, sessions, sessionCookies, request };
if (requestId.startsWith("oidc_")) {
try {
try {
if (requestId.startsWith("oidc_")) {
return await handleOIDCFlowInitiation(flowParams);
} catch (error) {
const status = isClassifiedError(error) ? error.httpStatus : 500;
return NextResponse.json({ error: "Authentication flow failed" }, { status });
}
} else if (requestId.startsWith("saml_")) {
try {
} else if (requestId.startsWith("saml_")) {
return await handleSAMLFlowInitiation(flowParams);
} catch (error) {
const status = isClassifiedError(error) ? error.httpStatus : 500;
return NextResponse.json({ error: "SAML flow failed" }, { status });
} else if (requestId.startsWith("device_")) {
// Device Authorization does not need to start here as it is handled on the /device endpoint
return NextResponse.json({ error: "Device authorization should use /device endpoint" }, { status: 400 });
} else {
return NextResponse.json({ error: "Invalid request ID format" }, { status: 400 });
}
} else if (requestId.startsWith("device_")) {
// Device Authorization does not need to start here as it is handled on the /device endpoint
return NextResponse.json({ error: "Device authorization should use /device endpoint" }, { status: 400 });
} else {
return NextResponse.json({ error: "Invalid request ID format" }, { status: 400 });
} catch (error: unknown) {
logger.error("Flow initiation failed", { requestId, error });
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
-22
View File
@@ -1,22 +0,0 @@
import { createServiceForHost } from "@/lib/service";
import { getServiceConfig } from "@/lib/service-url";
import { Client } from "@zitadel/client";
import { SettingsService } from "@zitadel/proto/zitadel/settings/v2/settings_service_pb";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
export async function GET() {
const _headers = await headers();
const { serviceConfig } = getServiceConfig(_headers);
const settingsService: Client<typeof SettingsService> = await createServiceForHost(SettingsService, serviceConfig);
const settings = await settingsService.getSecuritySettings({}).then((resp) => (resp.settings ? resp.settings : undefined));
const response = NextResponse.json({ settings }, { status: 200 });
// Add Cache-Control header to cache the response for up to 1 hour
response.headers.set("Cache-Control", "public, max-age=3600, stale-while-revalidate=86400");
return response;
}
+1 -1
View File
@@ -99,7 +99,7 @@ export function DynamicTheme({
<div className="relative mx-auto w-full max-w-[440px] px-4 py-4">
<Card>
<div className="mx-auto flex flex-col items-center space-y-8">
<div className="relative -mb-4 flex flex-row items-center justify-center">
<div className="relative flex flex-row items-center justify-center">
{branding && (
<Logo
lightSrc={branding.lightTheme?.logoUrl}
@@ -0,0 +1,37 @@
import { cleanup, render, screen } from "@testing-library/react";
import { create } from "@zitadel/client";
import { LegalAndSupportSettingsSchema } from "@zitadel/proto/zitadel/settings/v2/legal_settings_pb";
import type { AnchorHTMLAttributes } from "react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { PrivacyPolicyCheckboxes } from "./privacy-policy-checkboxes";
vi.mock("next-intl", () => ({
useLocale: () => "fr",
useTranslations: () => (key: string) => key,
}));
vi.mock("next/link", () => ({
default: ({ href, children, ...props }: AnchorHTMLAttributes<HTMLAnchorElement> & { href: string }) => (
<a href={href} {...props}>
{children}
</a>
),
}));
describe("PrivacyPolicyCheckboxes", () => {
afterEach(cleanup);
test("resolves language placeholders in legal links with the active locale", () => {
const legal = create(LegalAndSupportSettingsSchema, {
tosLink: "https://demo.com/tos-{{.Lang}}",
privacyPolicyLink: "https://demo.com/privacy-{{.Lang}}",
helpLink: "https://demo.com/help-{{.Lang}}",
});
render(<PrivacyPolicyCheckboxes legal={legal} onChange={vi.fn()} />);
expect(screen.getByTestId("tos-link")).toHaveAttribute("href", "https://demo.com/tos-fr");
expect(screen.getByTestId("privacy-policy-link")).toHaveAttribute("href", "https://demo.com/privacy-fr");
expect(screen.getByTestId("help-link")).toHaveAttribute("href", "https://demo.com/help-fr");
});
});
@@ -1,5 +1,7 @@
"use client";
import { resolveLocalizedLegalLink } from "@/lib/legal-links";
import { LegalAndSupportSettings } from "@zitadel/proto/zitadel/settings/v2/legal_settings_pb";
import { useLocale } from "next-intl";
import Link from "next/link";
import { useState } from "react";
import { Checkbox } from "./checkbox";
@@ -16,15 +18,19 @@ type AcceptanceState = {
};
export function PrivacyPolicyCheckboxes({ legal, onChange }: Props) {
const locale = useLocale();
const [acceptanceState, setAcceptanceState] = useState<AcceptanceState>({
tosAccepted: false,
privacyPolicyAccepted: false,
});
const helpLink = resolveLocalizedLegalLink(legal?.helpLink, locale);
const tosLink = resolveLocalizedLegalLink(legal?.tosLink, locale);
const privacyPolicyLink = resolveLocalizedLegalLink(legal?.privacyPolicyLink, locale);
// Helper function to check if all required checkboxes are accepted
const checkAllAccepted = (newState: AcceptanceState) => {
const hasTosLink = !!legal?.tosLink;
const hasPrivacyLink = !!legal?.privacyPolicyLink;
const hasTosLink = !!tosLink;
const hasPrivacyLink = !!privacyPolicyLink;
// Check that all required checkboxes are accepted
return (!hasTosLink || newState.tosAccepted) && (!hasPrivacyLink || newState.privacyPolicyAccepted);
@@ -34,9 +40,9 @@ export function PrivacyPolicyCheckboxes({ legal, onChange }: Props) {
<>
<p className="text-text-light-secondary-500 dark:text-text-dark-secondary-500 mt-4 flex flex-row items-center text-sm">
<Translated i18nKey="agreeTo" namespace="register" />
{legal?.helpLink && (
{helpLink && (
<span>
<Link href={legal.helpLink} target="_blank">
<Link href={helpLink} target="_blank" aria-label="Open help in a new tab" data-testid="help-link">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
@@ -55,7 +61,7 @@ export function PrivacyPolicyCheckboxes({ legal, onChange }: Props) {
</span>
)}
</p>
{legal?.tosLink && (
{tosLink && (
<div className="mt-4 flex items-center">
<Checkbox
className="mr-4"
@@ -74,14 +80,14 @@ export function PrivacyPolicyCheckboxes({ legal, onChange }: Props) {
<div className="mr-4 w-[28rem]">
<p className="text-text-light-500 dark:text-text-dark-500 text-sm">
<Link href={legal.tosLink} className="underline" target="_blank">
<Link href={tosLink} className="underline" target="_blank" data-testid="tos-link">
<Translated i18nKey="termsOfService" namespace="register" />
</Link>
</p>
</div>
</div>
)}
{legal?.privacyPolicyLink && (
{privacyPolicyLink && (
<div className="mt-4 flex items-center">
<Checkbox
className="mr-4"
@@ -100,7 +106,7 @@ export function PrivacyPolicyCheckboxes({ legal, onChange }: Props) {
<div className="mr-4 w-[28rem]">
<p className="text-text-light-500 dark:text-text-dark-500 text-sm">
<Link href={legal.privacyPolicyLink} className="underline" target="_blank">
<Link href={privacyPolicyLink} className="underline" target="_blank" data-testid="privacy-policy-link">
<Translated i18nKey="privacyPolicy" namespace="register" />
</Link>
</p>
+1 -1
View File
@@ -51,7 +51,7 @@ export function RegisterForm({
mode: "onChange",
defaultValues: {
email: email ?? "",
firstName: firstname ?? "",
firstname: firstname ?? "",
lastname: lastname ?? "",
},
});
@@ -18,6 +18,66 @@ export const ThemeWrapper = ({ children, branding }: Props) => {
setTheme(document, branding);
}, [branding]);
// Apply custom font from branding settings before paint to avoid FOUC.
// When a custom font is uploaded via the label/branding policy, fontUrl
// contains a fully-resolved URL to the font file served by the assets API.
// We inject a @font-face rule and set a CSS custom property so the entire
// login UI picks up the custom font with the existing font as fallback.
useLayoutEffect(() => {
const STYLE_ID = "zitadel-custom-font";
if (branding?.fontUrl) {
let fontSrc: string;
try {
fontSrc = new URL(branding.fontUrl).href;
} catch {
// Malformed URL — skip custom font
return;
}
let styleEl = document.getElementById(STYLE_ID) as HTMLStyleElement | null;
if (!styleEl) {
styleEl = document.createElement("style");
styleEl.id = STYLE_ID;
document.head.appendChild(styleEl);
}
// Capture the current font-family (Lato from next/font) before overriding,
// so it serves as fallback if the custom font fails to load.
const existingFont = getComputedStyle(document.documentElement).fontFamily || "sans-serif";
const fontStack = `'ZitadelCustomFont', ${existingFont}`;
styleEl.textContent = `
@font-face {
font-family: 'ZitadelCustomFont';
font-style: normal;
font-display: swap;
src: url('${fontSrc}');
}
`;
document.documentElement.style.setProperty("--zitadel-font-family", fontStack);
// Inline style overrides the class-based Lato from next/font
document.documentElement.style.setProperty("font-family", fontStack);
} else {
// No custom font — remove injected style and let Lato class take over
const existing = document.getElementById(STYLE_ID);
if (existing) {
existing.remove();
}
document.documentElement.style.removeProperty("--zitadel-font-family");
document.documentElement.style.removeProperty("font-family");
}
return () => {
const existing = document.getElementById(STYLE_ID);
if (existing) {
existing.remove();
}
document.documentElement.style.removeProperty("--zitadel-font-family");
document.documentElement.style.removeProperty("font-family");
};
}, [branding?.fontUrl]);
// Publish themeMode to the module-level store so ThemeSwitch can read it
useEffect(() => {
setThemeMode(branding?.themeMode ?? ThemeMode.UNSPECIFIED);
+3 -2
View File
@@ -15,11 +15,11 @@ describe("buildCSP", () => {
expect(csp).toContain("frame-ancestors 'none'");
});
test("adds serviceUrl to img-src only", () => {
test("adds serviceUrl to img-src and font-src", () => {
const csp = buildCSP({ serviceUrl: "https://my-instance.zitadel.cloud" });
expect(csp).toContain("img-src 'self' https://my-instance.zitadel.cloud");
expect(csp).toMatch(/font-src 'self'(?:;| |$)/);
expect(csp).toContain("font-src 'self' https://my-instance.zitadel.cloud");
});
test("keeps frame-ancestors as 'none' when iframeOrigins is empty", () => {
@@ -44,6 +44,7 @@ describe("buildCSP", () => {
});
expect(csp).toContain("img-src 'self' https://zitadel.mycompany.com");
expect(csp).toContain("font-src 'self' https://zitadel.mycompany.com");
expect(csp).toContain("frame-ancestors https://portal.mycompany.com");
expect(csp).not.toContain("frame-ancestors 'none'");
});
+2 -1
View File
@@ -11,7 +11,7 @@ const BASE_DIRECTIVES: Record<string, string[]> = {
export interface CSPOptions {
serviceUrl?: string;
iframeOrigins?: string[];
iframeOrigins?: string[] | null;
}
export function buildCSP(options: CSPOptions = {}): string {
@@ -19,6 +19,7 @@ export function buildCSP(options: CSPOptions = {}): string {
if (options.serviceUrl) {
directives["img-src"] = [...directives["img-src"], options.serviceUrl];
directives["font-src"] = [...directives["font-src"], options.serviceUrl];
}
if (options.iframeOrigins && options.iframeOrigins.length > 0) {
+35
View File
@@ -0,0 +1,35 @@
import { createLogger } from "@/lib/logger";
const logger = createLogger("custom-headers");
/**
* Parses the CUSTOM_REQUEST_HEADERS environment variable and applies the
* headers to a mutable target via the provided `set` and `remove` callbacks.
*
* Format: comma-separated "key:value" pairs.
* - Non-empty values call `set(key, value)`.
* - Empty values (e.g., "X-Remove-Me:") call `remove(key)` to delete the header.
* - Malformed entries (no colon) are logged and skipped.
*/
export function applyCustomHeaders(actions: {
set: (key: string, value: string) => void;
remove: (key: string) => void;
}): void {
const raw = process.env.CUSTOM_REQUEST_HEADERS;
if (!raw) return;
raw.split(",").forEach((header) => {
const kv = header.indexOf(":");
if (kv > 0) {
const key = header.slice(0, kv).trim();
const value = header.slice(kv + 1).trim();
if (value) {
actions.set(key, value);
} else {
actions.remove(key);
}
} else {
logger.warn("Skipping malformed CUSTOM_REQUEST_HEADERS entry", { header });
}
});
}
@@ -38,7 +38,15 @@ describe("ClassifiedConnectError", () => {
expect(classified.message).toContain("not found");
expect(classified.code).toBe(Code.NotFound);
expect(classified.name).toBe("ClassifiedConnectError");
// name must remain "ConnectError" so ConnectError's custom Symbol.hasInstance passes
expect(classified.name).toBe("ConnectError");
});
it("is instanceof ConnectError (required for ConnectError.from compatibility)", () => {
const source = new ConnectError("test", Code.FailedPrecondition);
const classified = new ClassifiedConnectError(source);
expect(classified instanceof ConnectError).toBe(true);
});
it("sets httpStatus from gRPC code", () => {
@@ -70,10 +70,10 @@ export class ClassifiedConnectError extends ConnectError {
constructor(source: ConnectError) {
super(source.rawMessage, source.code, source.metadata, undefined, source.cause);
// ConnectError's constructor resets the prototype chain via Object.setPrototypeOf.
// We must restore it so instanceof ClassifiedConnectError works correctly.
Object.setPrototypeOf(this, ClassifiedConnectError.prototype);
this.name = "ClassifiedConnectError";
// Keep name as "ConnectError" so that ConnectError's custom Symbol.hasInstance
// duck-typing check (v.name === "ConnectError") passes. The branded Symbol.for
// check via isClassifiedError() is used to distinguish this subclass instead.
this.name = "ConnectError";
// Preserve the original stack trace so debugging/alert triage can see the RPC call site.
if (source.stack) {
this.stack = source.stack;
@@ -67,7 +67,7 @@ describe("otelGrpcInterceptor", () => {
});
});
it("records error spans with gRPC status codes", async () => {
it("records error spans with gRPC status codes and classification", async () => {
const mockTransport = createRouterTransport(
({ service }) => {
service(SessionService, {
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { resolveLocalizedLegalLink } from "./legal-links";
describe("resolveLocalizedLegalLink", () => {
it("replaces the language placeholder with the active locale", () => {
expect(resolveLocalizedLegalLink("https://example.com/{{.Lang}}/terms", "de")).toBe("https://example.com/de/terms");
});
it("replaces every language placeholder in the link", () => {
expect(resolveLocalizedLegalLink("https://example.com/{{.Lang}}/docs?lang={{.Lang}}", "fr")).toBe(
"https://example.com/fr/docs?lang=fr",
);
});
it("returns the original link when no placeholder is present", () => {
expect(resolveLocalizedLegalLink("https://example.com/terms", "en")).toBe("https://example.com/terms");
});
it("returns the original link when locale is missing", () => {
expect(resolveLocalizedLegalLink("https://example.com/{{.Lang}}/terms", undefined)).toBe(
"https://example.com/{{.Lang}}/terms",
);
});
it("returns undefined when the link is missing", () => {
expect(resolveLocalizedLegalLink(undefined, "en")).toBeUndefined();
});
});
+9
View File
@@ -0,0 +1,9 @@
const LANGUAGE_PLACEHOLDER = "{{.Lang}}";
export function resolveLocalizedLegalLink(link: string | undefined, locale: string | undefined): string | undefined {
if (!link || !locale) {
return link;
}
return link.replaceAll(LANGUAGE_PLACEHOLDER, locale);
}
+11 -24
View File
@@ -1,6 +1,6 @@
import { getValidLocaleFromUILocales } from "@/lib/auth-utils";
import { getLanguageCookie, setLanguageCookie } from "@/lib/cookies";
import { isClassifiedError } from "@/lib/grpc/interceptors/error-classification";
import { shouldUILocalesOverrideCookie } from "@/lib/i18n";
import { idpTypeToSlug } from "@/lib/idp";
import { createLogger } from "@/lib/logger";
@@ -87,16 +87,7 @@ export interface FlowInitiationParams {
export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Promise<NextResponse> {
const { serviceConfig, requestId, sessions, sessionCookies, request } = params;
let authRequest;
try {
({ authRequest } = await getAuthRequest({ serviceConfig, authRequestId: requestId.replace("oidc_", "") }));
} catch (error) {
if (isClassifiedError(error) && error.isUserError) {
logger.warn("Auth request failed (client error)", { grpcCode: error.code, httpStatus: error.httpStatus });
return NextResponse.json({ error: error.message }, { status: error.httpStatus });
}
throw error;
}
const { authRequest } = await getAuthRequest({ serviceConfig, authRequestId: requestId.replace("oidc_", "") });
const locale = getValidLocaleFromUILocales(authRequest?.uiLocales);
if (locale) {
@@ -276,8 +267,14 @@ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Pr
}
return NextResponse.redirect(loginNameUrl);
} else if (authRequest.prompt.includes(Prompt.NONE)) {
const securitySettings = await getSecuritySettings({ serviceConfig });
let securitySettings: SecuritySettings | undefined;
try {
securitySettings = await getSecuritySettings({ serviceConfig });
} catch (err) {
logger.error("Failed to load security settings for CSP in prompt=none flow", {
error: err,
});
}
const selectedSession = await findValidSession({ serviceConfig, sessions, authRequest, organization });
const noSessionResponse = NextResponse.json({ error: "No active session found" }, { status: 400 });
@@ -311,7 +308,6 @@ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Pr
const callbackResponse = NextResponse.redirect(callbackUrl);
setCSPHeaders(callbackResponse, serviceConfig, securitySettings);
return callbackResponse;
} else {
let selectedSession = await findValidSession({ serviceConfig, sessions, authRequest, organization });
@@ -396,16 +392,7 @@ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Pr
export async function handleSAMLFlowInitiation(params: FlowInitiationParams): Promise<NextResponse> {
const { serviceConfig, requestId, sessions, sessionCookies, request } = params;
let samlRequest;
try {
({ samlRequest } = await getSAMLRequest({ serviceConfig, samlRequestId: requestId.replace("saml_", "") }));
} catch (error) {
if (isClassifiedError(error) && error.isUserError) {
logger.warn("SAML request failed (client error)", { grpcCode: error.code, httpStatus: error.httpStatus });
return NextResponse.json({ error: error.message }, { status: error.httpStatus });
}
throw error;
}
const { samlRequest } = await getSAMLRequest({ serviceConfig, samlRequestId: requestId.replace("saml_", "") });
if (!samlRequest) {
return NextResponse.json({ error: "No samlRequest found" }, { status: 400 });
-17
View File
@@ -39,23 +39,6 @@ describe("Host utility functions", () => {
expect(mockHeaders.get).toHaveBeenCalledWith("x-zitadel-instance-host");
expect(mockHeaders.get).toHaveBeenCalledWith("x-zitadel-forward-host");
});
test("should return null when neither x-zitadel-instance-host nor x-zitadel-forward-host are available", () => {
const mockHeaders = {
get: vi.fn((key: string) => {
if (key === "x-zitadel-instance-host") return null;
if (key === "x-zitadel-forward-host") return null;
if (key === "x-forwarded-host") return "accounts.mycompany.com";
if (key === "host") return "internal.server";
return null;
}),
} as any;
const result = getInstanceHost(mockHeaders);
expect(result).toBeNull();
expect(mockHeaders.get).toHaveBeenCalledWith("x-zitadel-instance-host");
expect(mockHeaders.get).toHaveBeenCalledWith("x-zitadel-forward-host");
});
});
describe("getPublicHostWithProtocol", () => {
+2 -1
View File
@@ -9,7 +9,8 @@ import { ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/he
*/
export function getInstanceHost(headers: ReadonlyHeaders): string | null {
// use standard proxy headers (x-forwarded-host → host) for both multi-tenant and self-hosted, do not use x-zitadel-instance-host
const instanceHost = headers.get("x-zitadel-instance-host") || headers.get("x-zitadel-forward-host");
const instanceHost =
headers.get("x-zitadel-instance-host") || headers.get("x-zitadel-forward-host") || headers.get("host");
return instanceHost;
}
+243 -1
View File
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { sendPasskey } from "./passkeys";
import { registerPasskeyLink, sendPasskey } from "./passkeys";
// Mock all the dependencies
vi.mock("next/headers", () => ({
@@ -19,8 +19,12 @@ vi.mock("../service-url", () => ({
vi.mock("../zitadel", () => ({
getLoginSettings: vi.fn(),
getSession: vi.fn(),
getUserByID: vi.fn(),
listUsers: vi.fn(),
createPasskeyRegistrationLink: vi.fn(),
registerPasskey: vi.fn(),
listAuthenticationMethodTypes: vi.fn(),
}));
vi.mock("./cookie", () => ({
@@ -36,6 +40,11 @@ vi.mock("../cookies", () => ({
vi.mock("../verify-helper", () => ({
checkEmailVerification: vi.fn(),
checkUserVerification: vi.fn(),
}));
vi.mock("./host", () => ({
getPublicHost: vi.fn(),
}));
vi.mock("../client", () => ({
@@ -72,6 +81,7 @@ describe("sendPasskey", () => {
const { getSessionCookieById, getSessionCookieByLoginName, getMostRecentSessionCookie } = await import("../cookies");
const { checkEmailVerification } = await import("../verify-helper");
const { completeFlowOrGetUrl } = await import("../client");
const { getPublicHost } = await import("./host");
// Setup mocks
mockHeaders = vi.mocked(headers);
@@ -90,6 +100,7 @@ describe("sendPasskey", () => {
mockGetMostRecentSessionCookie = vi.mocked(getMostRecentSessionCookie);
mockCheckEmailVerification = vi.mocked(checkEmailVerification);
mockCompleteFlowOrGetUrl = vi.mocked(completeFlowOrGetUrl);
vi.mocked(getPublicHost).mockReturnValue("test.com");
// Default mock implementations
const headersList = new Headers();
@@ -474,3 +485,234 @@ describe("sendPasskey", () => {
});
});
});
describe("registerPasskeyLink", () => {
let mockHeaders: any;
let mockGetServiceConfig: any;
let mockGetSession: any;
let mockGetSessionCookieById: any;
let mockCreatePasskeyRegistrationLink: any;
let mockRegisterPasskey: any;
let mockListAuthenticationMethodTypes: any;
let mockGetPublicHost: any;
let mockCheckUserVerification: any;
beforeEach(async () => {
vi.clearAllMocks();
const { headers } = await import("next/headers");
const { getServiceConfig } = await import("../service-url");
const { getSession, createPasskeyRegistrationLink, registerPasskey, listAuthenticationMethodTypes } =
await import("../zitadel");
const { getSessionCookieById } = await import("../cookies");
const { getPublicHost } = await import("./host");
const { checkUserVerification } = await import("../verify-helper");
mockHeaders = vi.mocked(headers);
mockGetServiceConfig = vi.mocked(getServiceConfig);
mockGetSession = vi.mocked(getSession);
mockGetSessionCookieById = vi.mocked(getSessionCookieById);
mockCreatePasskeyRegistrationLink = vi.mocked(createPasskeyRegistrationLink);
mockRegisterPasskey = vi.mocked(registerPasskey);
mockListAuthenticationMethodTypes = vi.mocked(listAuthenticationMethodTypes);
mockGetPublicHost = vi.mocked(getPublicHost);
mockCheckUserVerification = vi.mocked(checkUserVerification);
const headersList = new Headers();
headersList.set("host", "test.com");
mockHeaders.mockResolvedValue(headersList);
mockGetServiceConfig.mockReturnValue({ serviceConfig: { baseUrl: "https://example.com" } });
mockGetPublicHost.mockReturnValue("test.com");
});
test("should return error when neither sessionId nor userId is provided", async () => {
const result = await registerPasskeyLink({});
expect(result).toEqual({ error: "Either sessionId or userId must be provided" });
});
test("should return error when session cookie is not found", async () => {
mockGetSessionCookieById.mockResolvedValue(null);
const result = await registerPasskeyLink({ sessionId: "session-123" });
expect(result).toEqual({ error: "Could not get session cookie" });
});
describe("IDP-authenticated session", () => {
const sessionCookie = {
id: "session-123",
token: "session-token",
loginName: "max@zitadel.com",
};
const idpSession = {
session: {
id: "session-123",
factors: {
user: { id: "user-123", loginName: "max@zitadel.com" },
intent: {
verifiedAt: { seconds: BigInt(1700000000), nanos: 0 },
},
},
},
};
test("should succeed for session authenticated via IDP intent", async () => {
mockGetSessionCookieById.mockResolvedValue(sessionCookie);
mockGetSession.mockResolvedValue(idpSession);
mockCreatePasskeyRegistrationLink.mockResolvedValue({
code: { id: "code-id", code: "code-value" },
});
mockRegisterPasskey.mockResolvedValue({
passkeyId: "passkey-123",
publicKeyCredentialCreationOptions: {},
});
const result = await registerPasskeyLink({ sessionId: "session-123" });
expect(result).toHaveProperty("passkeyId");
expect(mockRegisterPasskey).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user-123",
domain: "test.com",
}),
);
});
test("should not require user verification or auth method check when IDP session is valid", async () => {
mockGetSessionCookieById.mockResolvedValue(sessionCookie);
mockGetSession.mockResolvedValue(idpSession);
mockCreatePasskeyRegistrationLink.mockResolvedValue({
code: { id: "code-id", code: "code-value" },
});
mockRegisterPasskey.mockResolvedValue({
passkeyId: "passkey-123",
publicKeyCredentialCreationOptions: {},
});
await registerPasskeyLink({ sessionId: "session-123" });
// Should NOT call listAuthenticationMethodTypes or checkUserVerification
// because the session is already valid via IDP intent
expect(mockListAuthenticationMethodTypes).not.toHaveBeenCalled();
expect(mockCheckUserVerification).not.toHaveBeenCalled();
});
});
describe("password-authenticated session", () => {
test("should succeed for session authenticated via password", async () => {
mockGetSessionCookieById.mockResolvedValue({
id: "session-123",
token: "session-token",
loginName: "max@zitadel.com",
});
mockGetSession.mockResolvedValue({
session: {
id: "session-123",
factors: {
user: { id: "user-123", loginName: "max@zitadel.com" },
password: {
verifiedAt: { seconds: BigInt(1700000000), nanos: 0 },
},
},
},
});
mockCreatePasskeyRegistrationLink.mockResolvedValue({
code: { id: "code-id", code: "code-value" },
});
mockRegisterPasskey.mockResolvedValue({
passkeyId: "passkey-123",
publicKeyCredentialCreationOptions: {},
});
const result = await registerPasskeyLink({ sessionId: "session-123" });
expect(result).toHaveProperty("passkeyId");
});
});
describe("session with no valid factors", () => {
test("should return error when session has no verified factors and user has auth methods", async () => {
mockGetSessionCookieById.mockResolvedValue({
id: "session-123",
token: "session-token",
loginName: "max@zitadel.com",
});
mockGetSession.mockResolvedValue({
session: {
id: "session-123",
factors: {
user: { id: "user-123", loginName: "max@zitadel.com" },
// No password, no webAuthN, no intent
},
},
});
mockListAuthenticationMethodTypes.mockResolvedValue({
authMethodTypes: [1], // has at least one auth method
});
const result = await registerPasskeyLink({ sessionId: "session-123" });
expect(result).toEqual({
error: "You have to authenticate or have a valid User Verification Check",
});
});
test("should check user verification when session has no factors and user has no auth methods", async () => {
mockGetSessionCookieById.mockResolvedValue({
id: "session-123",
token: "session-token",
loginName: "max@zitadel.com",
});
mockGetSession.mockResolvedValue({
session: {
id: "session-123",
factors: {
user: { id: "user-123", loginName: "max@zitadel.com" },
},
},
});
mockListAuthenticationMethodTypes.mockResolvedValue({
authMethodTypes: [],
});
mockCheckUserVerification.mockResolvedValue(false);
const result = await registerPasskeyLink({ sessionId: "session-123" });
expect(result).toEqual({
error: "User Verification Check has to be done",
});
expect(mockCheckUserVerification).toHaveBeenCalledWith("user-123");
});
test("should proceed when session has no factors but user verification passes", async () => {
mockGetSessionCookieById.mockResolvedValue({
id: "session-123",
token: "session-token",
loginName: "max@zitadel.com",
});
mockGetSession.mockResolvedValue({
session: {
id: "session-123",
factors: {
user: { id: "user-123", loginName: "max@zitadel.com" },
},
},
});
mockListAuthenticationMethodTypes.mockResolvedValue({
authMethodTypes: [],
});
mockCheckUserVerification.mockResolvedValue(true);
mockCreatePasskeyRegistrationLink.mockResolvedValue({
code: { id: "code-id", code: "code-value" },
});
mockRegisterPasskey.mockResolvedValue({
passkeyId: "passkey-123",
publicKeyCredentialCreationOptions: {},
});
const result = await registerPasskeyLink({ sessionId: "session-123" });
expect(result).toHaveProperty("passkeyId");
});
});
});
+3 -2
View File
@@ -51,10 +51,11 @@ function isSessionValid(session: Partial<Session>): {
} {
const validPassword = session?.factors?.password?.verifiedAt;
const validPasskey = session?.factors?.webAuthN?.verifiedAt;
const validIDP = session?.factors?.intent?.verifiedAt;
const stillValid = session.expirationDate ? timestampDate(session.expirationDate) > new Date() : true;
const verifiedAt = validPassword || validPasskey;
const valid = !!((validPassword || validPasskey) && stillValid);
const verifiedAt = validPassword || validPasskey || validIDP;
const valid = !!((validPassword || validPasskey || validIDP) && stillValid);
return { valid, verifiedAt };
}
+8 -2
View File
@@ -1,5 +1,7 @@
import { Code, ConnectError } from "@connectrpc/connect";
import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { ClassifiedConnectError } from "../grpc/interceptors/error-classification";
import { changePassword, checkSessionAndSetPassword, resetPassword, sendPassword } from "./password";
// Mock dependencies
@@ -273,8 +275,12 @@ describe("checkSessionAndSetPassword", () => {
});
test("should handle setPassword failure with failed precondition", async () => {
const { ConnectError } = await import("@zitadel/client");
mockSetPassword.mockRejectedValue(new ConnectError("User is not yet initialized", 9));
// Use real ConnectError + ClassifiedConnectError to match runtime behavior
// where the error-classification interceptor wraps transport errors.
const classifiedError = new ClassifiedConnectError(
new ConnectError("User is not yet initialized", Code.FailedPrecondition),
);
mockSetPassword.mockRejectedValue(classifiedError);
const result = await checkSessionAndSetPassword({
sessionId: "session123",
+3 -2
View File
@@ -1,5 +1,6 @@
"use server";
import { isClassifiedError } from "@/lib/grpc/interceptors/error-classification";
import { createLogger } from "@/lib/logger";
import { recordAuthAttempt, recordAuthFailure, recordAuthSuccess } from "@/lib/metrics";
import { createSessionAndUpdateCookie, setSessionAndUpdateCookie } from "@/lib/server/cookie";
@@ -15,7 +16,7 @@ import {
setPassword,
setUserPassword,
} from "@/lib/zitadel";
import { Code, ConnectError, create, Duration } from "@zitadel/client";
import { Code, create, Duration } from "@zitadel/client";
import { Checks, ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
import { User, UserState } from "@zitadel/proto/zitadel/user/v2/user_pb";
@@ -585,7 +586,7 @@ export async function checkSessionAndSetPassword({
return setPassword({ serviceConfig, payload }).catch((error) => {
// throw error if failed precondition (ex. User is not yet initialized)
if (error instanceof ConnectError && error.code === Code.FailedPrecondition && error.message) {
if (isClassifiedError(error) && error.code === Code.FailedPrecondition && error.message) {
return { error: t("errors.failedPrecondition") };
}
return { error: "Could not set password" };
+35 -3
View File
@@ -2,17 +2,49 @@
import { createSessionAndUpdateCookie, createSessionForIdpAndUpdateCookie } from "@/lib/server/cookie";
import { addHumanUser, addIDPLink, getLoginSettings, getUserByID, listAuthenticationMethodTypes } from "@/lib/zitadel";
import { create } from "@zitadel/client";
import { Code, ConnectError, Duration, create } from "@zitadel/client";
import { Factors } from "@zitadel/proto/zitadel/session/v2/session_pb";
import { ChecksJson, ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import { Checks, ChecksJson, ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import crypto from "crypto";
import { getTranslations } from "next-intl/server";
import { cookies, headers } from "next/headers";
import { completeFlowOrGetUrl } from "../client";
import { getOrSetFingerprintId } from "../fingerprint";
import { createLogger } from "../logger";
import { getServiceConfig } from "../service-url";
import { checkEmailVerification, checkMFAFactors } from "../verify-helper";
const logger = createLogger("register");
const MAX_SESSION_RETRIES = 3;
const RETRY_DELAYS_MS = [500, 1000, 2000];
/**
* After user creation, backend projections (users, login_names) may not be updated yet.
* This helper retries createSessionAndUpdateCookie on NotFound errors with increasing delays.
*/
async function createSessionWithRetry(command: { checks: Checks; requestId: string | undefined; lifetime?: Duration }) {
let lastError: unknown;
for (let attempt = 0; attempt < MAX_SESSION_RETRIES; attempt++) {
try {
return await createSessionAndUpdateCookie(command);
} catch (error) {
lastError = error;
const isNotFound = error instanceof ConnectError && error.code === Code.NotFound;
const isLastAttempt = attempt + 1 >= MAX_SESSION_RETRIES;
if (!isNotFound || isLastAttempt) {
throw error;
}
const delay = RETRY_DELAYS_MS[attempt] ?? 2000;
logger.warn(
`Session creation failed with NotFound (attempt ${attempt + 1}/${MAX_SESSION_RETRIES}), retrying in ${delay}ms...`,
);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
throw lastError;
}
type RegisterUserCommand = {
email: string;
firstName: string;
@@ -74,7 +106,7 @@ export async function registerUser(
const checks = create(ChecksSchema, checkPayload);
const result = await createSessionAndUpdateCookie({
const result = await createSessionWithRetry({
checks,
requestId: command.requestId,
lifetime: command.password ? loginSettings?.passwordCheckLifetime : undefined,
@@ -0,0 +1,135 @@
import { PromiseCache } from "@/lib/cache";
import { applyCustomHeaders } from "@/lib/custom-headers";
import { createLogger } from "@/lib/logger";
import type { GetSecuritySettingsResponseJson } from "@zitadel/proto/zitadel/settings/v2/settings_service_pb";
const logger = createLogger("security-settings");
let cacheConfig: Record<string, number> = {};
try {
if (process.env.API_CACHE_CONFIG) {
cacheConfig = JSON.parse(process.env.API_CACHE_CONFIG);
}
} catch (e) {
console.error("Failed to parse API_CACHE_CONFIG", e);
}
/** Cache TTL: defaults to longMinutes (1 hour). Security settings rarely change. */
const CACHE_TTL_MS = (cacheConfig.longMinutes ?? 60) * 60 * 1000;
/**
* Bounded LRU cache for security settings per instance host.
*
* Uses the shared PromiseCache which provides:
* - LRU eviction (capped by API_CACHE_CONFIG maxSize, default 10 000)
* - Automatic TTL expiry (API_CACHE_CONFIG longMinutes, default 60 min)
* - Stale-while-revalidate (serves stale data while refreshing in background)
* - Built-in in-flight request deduplication
*/
const cache = new PromiseCache(Number(cacheConfig.maxSize) || 10_000);
/**
* Resolves an authentication token from available credential sources.
* Priority: system user JWT > login client key > service account token.
*/
async function resolveAuthToken(): Promise<string> {
const { hasSystemUserCredentials, hasLoginClientKey, hasServiceUserToken } = await import("@/lib/deployment");
if (hasSystemUserCredentials()) {
const { systemAPIToken } = await import("@/lib/api");
return systemAPIToken();
}
if (hasLoginClientKey()) {
const { loginClientKeyToken } = await import("@/lib/api");
return loginClientKeyToken();
}
if (hasServiceUserToken()) {
return process.env.ZITADEL_SERVICE_USER_TOKEN!;
}
throw new Error("No authentication credentials found");
}
/**
* Fetches iframe origins from security settings using the ZITADEL API directly
* via the Connect protocol (POST + JSON). This uses raw fetch (no connectRPC
* node transport) so it stays compatible with the Next.js proxy runtime.
*
* Uses the same header pattern as createServerTransport in zitadel.ts:
* - baseUrl (ZITADEL_API_URL) as the fetch URL
* - x-zitadel-instance-host and x-zitadel-public-host as headers
* - CUSTOM_REQUEST_HEADERS applied
*
* Results are cached in-memory for 1 hour per instance host using a bounded
* LRU cache. Concurrent requests for the same key share a single in-flight
* promise to prevent thundering-herd stampedes on the backend.
*
* @param baseUrl - The ZITADEL API base URL (ZITADEL_API_URL)
* @param instanceHost - Optional instance host for multi-tenant routing
* @param publicHost - Optional public host for multi-tenant routing
* @returns An array of allowed iframe origins, or null if not configured
*/
export async function getIframeOrigins(
baseUrl: string,
instanceHost?: string,
publicHost?: string,
): Promise<string[] | null> {
const cacheKey = instanceHost || "__default__";
// The fetcher returns null (not undefined) because lru-cache treats
// undefined as a fetch failure.
return cache.getOrFetch<string[] | null>(
cacheKey,
() => fetchIframeOrigins(baseUrl, instanceHost, publicHost),
CACHE_TTL_MS,
);
}
async function fetchIframeOrigins(baseUrl: string, instanceHost?: string, publicHost?: string): Promise<string[] | null> {
const token = await resolveAuthToken();
const reqHeaders: Record<string, string> = {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
};
// Apply instance/public host headers — same pattern as createServerTransport
if (instanceHost) {
reqHeaders["x-zitadel-instance-host"] = instanceHost;
}
if (publicHost) {
reqHeaders["x-zitadel-public-host"] = publicHost;
}
// Apply custom headers from environment
applyCustomHeaders({
set: (key, value) => {
reqHeaders[key] = value;
},
remove: (key) => {
delete reqHeaders[key];
},
});
const response = await fetch(`${baseUrl}/zitadel.settings.v2.SettingsService/GetSecuritySettings`, {
method: "POST",
headers: reqHeaders,
body: "{}",
cache: "no-store",
});
if (!response.ok) {
logger.error("Failed to fetch security settings from API", {
status: response.status,
statusText: response.statusText,
});
// Return null instead of undefined — lru-cache treats undefined as a
// fetch failure and throws "fetch() returned undefined".
return null;
}
const data: GetSecuritySettingsResponseJson = await response.json();
const settings = data.settings;
const origins = settings?.embeddedIframe?.enabled ? settings.embeddedIframe.allowedOrigins : null;
return origins && origins.length > 0 ? origins : null;
}
-18
View File
@@ -24,24 +24,6 @@ describe("Service URL utilities", () => {
expect(() => getServiceConfig(mockHeaders)).toThrow("ZITADEL_API_URL is not set");
});
test("should return only baseUrl when x-zitadel-forward-host is not present (self-hosted)", () => {
process.env.ZITADEL_API_URL = "https://zitadel.mycompany.com";
const mockHeaders = {
get: vi.fn((key: string) => {
if (key === "x-zitadel-forward-host") return null;
if (key === "host") return "mycompany.com";
return null;
}),
} as any;
const result = getServiceConfig(mockHeaders);
expect(result.serviceConfig.baseUrl).toBe("https://zitadel.mycompany.com");
expect(result.serviceConfig.instanceHost).toBeUndefined();
expect(result.serviceConfig.publicHost).toBe("mycompany.com");
});
test("should use x-zitadel-forward-host when present (multi-tenant)", () => {
process.env.ZITADEL_API_URL = "https://api.zitadel.cloud";
+8 -22
View File
@@ -32,16 +32,14 @@ import {
import { getTranslations } from "next-intl/server";
import { getUserAgent } from "./fingerprint";
import { createLogger } from "./logger";
import { errorClassificationInterceptor } from "@/lib/grpc/interceptors/error-classification";
import { applyCustomHeaders } from "@/lib/custom-headers";
import { errorClassificationInterceptor, isClassifiedError } from "@/lib/grpc/interceptors/error-classification";
import { otelGrpcInterceptor } from "@/lib/grpc/interceptors/otel";
import { Code, ConnectError, Interceptor } from "@connectrpc/connect";
import { Code, Interceptor } from "@connectrpc/connect";
import { PromiseCache } from "./cache";
import { createServiceForHost } from "./service";
const logger = createLogger("zitadel");
const useCache = process.env.API_CACHE_ENABLED !== "false";
let cacheConfig: Record<string, number> = {};
@@ -1169,7 +1167,7 @@ export async function setUserPassword({
return userService.setPassword(payload, {}).catch((error) => {
// throw error if failed precondition (ex. User is not yet initialized)
if (error instanceof ConnectError && error.code === Code.FailedPrecondition && error.message) {
if (isClassifiedError(error) && error.code === Code.FailedPrecondition && error.message) {
return { error: error.message };
} else {
throw error;
@@ -1370,22 +1368,10 @@ export function createServerTransport(token: string, serviceConfig: ServiceConfi
}
// Apply headers from CUSTOM_REQUEST_HEADERS environment variable
if (process.env.CUSTOM_REQUEST_HEADERS) {
process.env.CUSTOM_REQUEST_HEADERS.split(",").forEach((header) => {
const kv = header.indexOf(":");
if (kv > 0) {
const key = header.slice(0, kv).trim();
const value = header.slice(kv + 1).trim();
if (value) {
req.header.set(key, value);
} else {
req.header.delete(key);
}
} else {
logger.warn("Skipping malformed header", { header });
}
});
}
applyCustomHeaders({
set: (key, value) => req.header.set(key, value),
remove: (key) => req.header.delete(key),
});
return next(req);
};
+32 -49
View File
@@ -1,34 +1,16 @@
import { SecuritySettings } from "@zitadel/proto/zitadel/settings/v2/security_settings_pb";
import { headers } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
import { buildCSP } from "./lib/csp";
import { applyCustomHeaders } from "./lib/custom-headers";
import { createLogger } from "./lib/logger";
import { getIframeOrigins } from "./lib/server/security-settings";
import { getServiceConfig } from "./lib/service-url";
const logger = createLogger("middleware");
export const config = {
matcher: ["/.well-known/:path*", "/oauth/:path*", "/oidc/:path*", "/idps/callback/:path*", "/saml/:path*", "/:path*"],
};
async function loadSecuritySettings(request: NextRequest): Promise<SecuritySettings | null> {
const securityResponse = await fetch(`${request.nextUrl.origin}/security`);
if (!securityResponse.ok) {
logger.error("Failed to fetch security settings:", { status: securityResponse.statusText });
return null;
}
const response = await securityResponse.json();
if (!response || !response.settings) {
logger.error("No security settings found in the response.");
return null;
}
return response.settings;
}
export async function proxy(request: NextRequest) {
// Add the original URL as a header to all requests
const requestHeaders = new Headers(request.headers);
@@ -40,50 +22,45 @@ export async function proxy(request: NextRequest) {
}
// Internal infrastructure routes — skip middleware entirely.
// /security is the internal API this middleware fetches (loop prevention).
// /healthy and /ready are Kubernetes/Docker health probes that must respond
// without depending on a ZITADEL backend.
const skipPaths = ["/security", "/healthy", "/ready"];
const skipPaths = ["/healthy", "/ready"];
if (skipPaths.includes(request.nextUrl.pathname)) {
return NextResponse.next({ request: { headers: requestHeaders } });
}
// Build security response headers (shared by all routes).
// Wrapped in try/catch so the middleware gracefully degrades with a default
// CSP when the ZITADEL backend is unavailable (e.g. during container startup).
const { serviceConfig } = getServiceConfig(request.headers);
const { baseUrl, publicHost, instanceHost } = serviceConfig;
// Build CSP headers using security settings fetched directly from the
// ZITADEL API (no self-loopback through the load balancer).
const responseHeaders = new Headers();
let publicHost: string | undefined;
let instanceHost: string | undefined;
let baseUrl: string | undefined;
try {
const _headers = await headers();
const { serviceConfig } = getServiceConfig(_headers);
publicHost = serviceConfig.publicHost;
instanceHost = serviceConfig.instanceHost;
baseUrl = serviceConfig.baseUrl;
const cspFetchEnabled = process.env.CSP_FETCH_ENABLED !== "false";
const securitySettings = await loadSecuritySettings(request);
const iframeOrigins =
securitySettings?.embeddedIframe?.enabled && securitySettings.embeddedIframe.allowedOrigins.length > 0
? securitySettings.embeddedIframe.allowedOrigins
: undefined;
if (cspFetchEnabled) {
try {
const iframeOrigins = await getIframeOrigins(baseUrl, instanceHost, publicHost);
responseHeaders.set("Content-Security-Policy", buildCSP({ serviceUrl: baseUrl, iframeOrigins }));
responseHeaders.set("Content-Security-Policy", buildCSP({ serviceUrl: baseUrl, iframeOrigins }));
if (!iframeOrigins) {
if (!iframeOrigins) {
responseHeaders.set("X-Frame-Options", "deny");
}
} catch (err) {
logger.error("Failed to load security settings for CSP, using default CSP", {
error: err instanceof Error ? err.message : String(err),
});
responseHeaders.set("Content-Security-Policy", buildCSP({ serviceUrl: baseUrl }));
responseHeaders.set("X-Frame-Options", "deny");
}
} catch (err) {
logger.error("Failed to load security settings for CSP, using default CSP", {
error: err instanceof Error ? err.message : String(err),
});
responseHeaders.set("Content-Security-Policy", buildCSP());
} else {
responseHeaders.set("Content-Security-Policy", buildCSP({ serviceUrl: baseUrl }));
responseHeaders.set("X-Frame-Options", "deny");
}
// Only run the rest of the logic for the original matcher paths
const proxyPaths = ["/.well-known/", "/oauth/", "/oidc/", "/idps/callback/", "/saml/"];
// Only proxy paths need to be rewritten to the ZITADEL backend
const proxyPaths = ["/.well-known/", "/oauth/", "/oidc/", "/idps/callback/", "/saml/", "/assets/"];
const isMatched = proxyPaths.some((prefix) => request.nextUrl.pathname.startsWith(prefix));
if (!isMatched) {
@@ -101,6 +78,12 @@ export async function proxy(request: NextRequest) {
requestHeaders.set("x-zitadel-instance-host", instanceHost);
}
// Apply headers from CUSTOM_REQUEST_HEADERS environment variable
applyCustomHeaders({
set: (key, value) => requestHeaders.set(key, value),
remove: (key) => requestHeaders.delete(key),
});
responseHeaders.set("Access-Control-Allow-Origin", "*");
responseHeaders.set("Access-Control-Allow-Headers", "*");
+1 -1
View File
@@ -1,5 +1,5 @@
// https://github.com/tailwindlabs/tailwindcss/discussions/16173
@import "tailwindcss";
@use "tailwindcss";
@config "../../tailwind.config.mjs";
@layer base {
@@ -0,0 +1,14 @@
package convert
import (
"github.com/zitadel/zitadel/backend/v3/storage/database"
objpb "github.com/zitadel/zitadel/pkg/grpc/object"
)
var grpcTimestampOpToDomain = map[objpb.TimestampQueryMethod]database.NumberOperation{
objpb.TimestampQueryMethod_TIMESTAMP_QUERY_METHOD_EQUALS: database.NumberOperationEqual,
objpb.TimestampQueryMethod_TIMESTAMP_QUERY_METHOD_GREATER: database.NumberOperationGreaterThan,
objpb.TimestampQueryMethod_TIMESTAMP_QUERY_METHOD_GREATER_OR_EQUALS: database.NumberOperationGreaterThanOrEqual,
objpb.TimestampQueryMethod_TIMESTAMP_QUERY_METHOD_LESS: database.NumberOperationLessThan,
objpb.TimestampQueryMethod_TIMESTAMP_QUERY_METHOD_LESS_OR_EQUALS: database.NumberOperationLessThanOrEqual,
}
@@ -0,0 +1,213 @@
package convert
import (
"context"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/zitadel/zitadel/backend/v3/domain"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/zerrors"
session_grpc "github.com/zitadel/zitadel/pkg/grpc/session/v2"
)
// ListSessionsRequestGRPCToDomain converts a gRPC [session_grpc.ListSessionsRequest] to
// the domain [domain.ListSessionsRequest]. It validates the request and returns an error
// for any invalid filter values.
func ListSessionsRequestGRPCToDomain(ctx context.Context, req *session_grpc.ListSessionsRequest) (*domain.ListSessionsRequest, error) {
filters, err := searchQueriesGRPCToDomain(ctx, req.GetQueries())
if err != nil {
return nil, err
}
sortCol := domain.SessionSortColumnUnspecified
if req.GetSortingColumn() == session_grpc.SessionFieldName_SESSION_FIELD_NAME_CREATION_DATE {
sortCol = domain.SessionSortColumnCreationDate
}
return &domain.ListSessionsRequest{
Filters: filters,
SortColumn: sortCol,
Ascending: req.GetQuery().GetAsc(),
Limit: req.GetQuery().GetLimit(),
Offset: uint32(req.GetQuery().GetOffset()),
}, nil
}
func searchQueriesGRPCToDomain(ctx context.Context, queries []*session_grpc.SearchQuery) ([]domain.SessionFilter, error) {
if len(queries) == 0 {
return nil, nil
}
filters := make([]domain.SessionFilter, 0, len(queries))
for _, q := range queries {
f, err := searchQueryGRPCToDomain(ctx, q)
if err != nil {
return nil, err
}
filters = append(filters, f)
}
return filters, nil
}
func searchQueryGRPCToDomain(ctx context.Context, q *session_grpc.SearchQuery) (domain.SessionFilter, error) {
switch typedQuery := q.GetQuery().(type) {
case *session_grpc.SearchQuery_IdsQuery:
return domain.SessionIDsFilter{IDs: typedQuery.IdsQuery.GetIds()}, nil
case *session_grpc.SearchQuery_UserIdQuery:
return domain.SessionUserIDFilter{UserID: typedQuery.UserIdQuery.GetId()}, nil
case *session_grpc.SearchQuery_CreationDateQuery:
return domain.SessionCreationDateFilter{
Op: grpcTimestampOpToDomain[typedQuery.CreationDateQuery.GetMethod()],
Date: typedQuery.CreationDateQuery.GetCreationDate().AsTime(),
}, nil
case *session_grpc.SearchQuery_CreatorQuery:
id := authz.GetCtxData(ctx).UserID
if typedQuery.CreatorQuery != nil && typedQuery.CreatorQuery.Id != nil {
if typedQuery.CreatorQuery.GetId() == "" {
return nil, zerrors.ThrowInvalidArgument(nil, "DOM-x8n24uh", "List.Query.Invalid")
}
id = typedQuery.CreatorQuery.GetId()
}
return domain.SessionCreatorFilter{ID: id}, nil
case *session_grpc.SearchQuery_UserAgentQuery:
fp := authz.GetCtxData(ctx).AgentID
if typedQuery.UserAgentQuery != nil && typedQuery.UserAgentQuery.FingerprintId != nil {
if typedQuery.UserAgentQuery.GetFingerprintId() == "" {
return nil, zerrors.ThrowInvalidArgument(nil, "DOM-x8n23uh", "List.Query.Invalid")
}
fp = typedQuery.UserAgentQuery.GetFingerprintId()
}
return domain.SessionUserAgentFilter{FingerprintID: fp}, nil
case *session_grpc.SearchQuery_ExpirationDateQuery:
return domain.SessionExpirationDateFilter{
Op: grpcTimestampOpToDomain[typedQuery.ExpirationDateQuery.GetMethod()],
Date: typedQuery.ExpirationDateQuery.GetExpirationDate().AsTime(),
}, nil
default:
return nil, zerrors.ThrowInvalidArgumentf(nil, "CONV-Cz5s3t", "session search query %T not implemented", typedQuery)
}
}
// DomainSessionListToGRPCResponse converts a slice of domain sessions to gRPC response sessions.
func DomainSessionListToGRPCResponse(sessions []*domain.Session) []*session_grpc.Session {
out := make([]*session_grpc.Session, len(sessions))
for i, s := range sessions {
out[i] = DomainSessionToGRPCResponse(s)
}
return out
}
// DomainSessionToGRPCResponse converts a single domain session to its gRPC representation.
func DomainSessionToGRPCResponse(session *domain.Session) *session_grpc.Session {
pb := &session_grpc.Session{
Id: session.ID,
CreationDate: timestamppb.New(session.CreatedAt),
ChangeDate: timestamppb.New(session.UpdatedAt),
Factors: domainFactorsToGRPC(session),
UserAgent: domainUserAgentToGRPC(session.UserAgent),
}
if len(session.Metadata) > 0 {
pb.Metadata = make(map[string][]byte, len(session.Metadata))
for _, m := range session.Metadata {
pb.Metadata[m.Key] = m.Value
}
}
if !session.Expiration.IsZero() {
pb.ExpirationDate = timestamppb.New(session.Expiration)
}
return pb
}
func domainFactorsToGRPC(session *domain.Session) *session_grpc.Factors {
userFactor := session.Factors.GetUserFactor()
if userFactor == nil {
return nil
}
pb := &session_grpc.Factors{
User: &session_grpc.UserFactor{
VerifiedAt: timestamppb.New(userFactor.LastVerifiedAt),
Id: userFactor.UserID,
},
}
pb.User.LoginName = session.UserPreferredLoginName
if session.UserOrganizationID != nil {
pb.User.OrganizationId = *session.UserOrganizationID
}
if session.UserDisplayName != nil {
pb.User.DisplayName = *session.UserDisplayName
}
if f := session.Factors.GetPasswordFactor(); f != nil {
pb.Password = &session_grpc.PasswordFactor{
VerifiedAt: timestamppb.New(f.LastVerifiedAt),
}
}
if f := session.Factors.GetPasskeyFactor(); f != nil {
pb.WebAuthN = &session_grpc.WebAuthNFactor{
VerifiedAt: timestamppb.New(f.LastVerifiedAt),
UserVerified: f.UserVerified,
}
}
if f := session.Factors.GetIDPIntentFactor(); f != nil {
pb.Intent = &session_grpc.IntentFactor{
VerifiedAt: timestamppb.New(f.LastVerifiedAt),
}
}
if f := session.Factors.GetTOTPFactor(); f != nil {
pb.Totp = &session_grpc.TOTPFactor{
VerifiedAt: timestamppb.New(f.LastVerifiedAt),
}
}
if f := session.Factors.GetOTPSMSFactor(); f != nil {
pb.OtpSms = &session_grpc.OTPFactor{
VerifiedAt: timestamppb.New(f.LastVerifiedAt),
}
}
if f := session.Factors.GetOTPEmailFactor(); f != nil {
pb.OtpEmail = &session_grpc.OTPFactor{
VerifiedAt: timestamppb.New(f.LastVerifiedAt),
}
}
if f := session.Factors.GetRecoveryCodeFactor(); f != nil {
pb.RecoveryCode = &session_grpc.RecoveryCodeFactor{
VerifiedAt: timestamppb.New(f.LastVerifiedAt),
}
}
return pb
}
func domainUserAgentToGRPC(ua *domain.SessionUserAgent) *session_grpc.UserAgent {
if ua == nil {
return nil
}
pb := &session_grpc.UserAgent{
FingerprintId: ua.FingerprintID,
Description: ua.Description,
}
if ua.IP != nil {
ip := ua.IP.String()
pb.Ip = &ip
}
if len(ua.Header) > 0 {
pb.Header = make(map[string]*session_grpc.UserAgent_HeaderValues, len(ua.Header))
for k, v := range ua.Header {
pb.Header[k] = &session_grpc.UserAgent_HeaderValues{Values: v}
}
}
return pb
}
@@ -0,0 +1,606 @@
package convert
import (
"net"
"net/http"
"testing"
"time"
"github.com/muhlemmer/gu"
"github.com/stretchr/testify/assert"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/zitadel/zitadel/backend/v3/domain"
"github.com/zitadel/zitadel/backend/v3/storage/database"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/zerrors"
objpb "github.com/zitadel/zitadel/pkg/grpc/object"
objv2 "github.com/zitadel/zitadel/pkg/grpc/object/v2"
session_grpc "github.com/zitadel/zitadel/pkg/grpc/session/v2"
)
func TestSearchQueryGRPCToDomain(t *testing.T) {
t.Parallel()
now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
nowPB := timestamppb.New(now)
creatorID := "creator-123"
fingerprintID := "fp-123"
userID := "user-1"
agentID := "agent-1"
emptyStr := ""
tests := []struct {
name string
input *session_grpc.SearchQuery
want domain.SessionFilter
wantErr error
}{
{
name: "IdsQuery maps to SessionIDsFilter",
input: &session_grpc.SearchQuery{
Query: &session_grpc.SearchQuery_IdsQuery{
IdsQuery: &session_grpc.IDsQuery{Ids: []string{"id1", "id2"}},
},
},
want: domain.SessionIDsFilter{IDs: []string{"id1", "id2"}},
},
{
name: "UserIdQuery maps to SessionUserIDFilter",
input: &session_grpc.SearchQuery{
Query: &session_grpc.SearchQuery_UserIdQuery{
UserIdQuery: &session_grpc.UserIDQuery{Id: "user-123"},
},
},
want: domain.SessionUserIDFilter{UserID: "user-123"},
},
{
name: "CreationDateQuery with EQUALS method",
input: &session_grpc.SearchQuery{
Query: &session_grpc.SearchQuery_CreationDateQuery{
CreationDateQuery: &session_grpc.CreationDateQuery{
CreationDate: nowPB,
Method: objpb.TimestampQueryMethod_TIMESTAMP_QUERY_METHOD_EQUALS,
},
},
},
want: domain.SessionCreationDateFilter{Op: database.NumberOperationEqual, Date: now},
},
{
name: "CreationDateQuery with GREATER method",
input: &session_grpc.SearchQuery{
Query: &session_grpc.SearchQuery_CreationDateQuery{
CreationDateQuery: &session_grpc.CreationDateQuery{
CreationDate: nowPB,
Method: objpb.TimestampQueryMethod_TIMESTAMP_QUERY_METHOD_GREATER,
},
},
},
want: domain.SessionCreationDateFilter{Op: database.NumberOperationGreaterThan, Date: now},
},
{
name: "CreationDateQuery with GREATER_OR_EQUALS method",
input: &session_grpc.SearchQuery{
Query: &session_grpc.SearchQuery_CreationDateQuery{
CreationDateQuery: &session_grpc.CreationDateQuery{
CreationDate: nowPB,
Method: objpb.TimestampQueryMethod_TIMESTAMP_QUERY_METHOD_GREATER_OR_EQUALS,
},
},
},
want: domain.SessionCreationDateFilter{Op: database.NumberOperationGreaterThanOrEqual, Date: now},
},
{
name: "CreationDateQuery with LESS method",
input: &session_grpc.SearchQuery{
Query: &session_grpc.SearchQuery_CreationDateQuery{
CreationDateQuery: &session_grpc.CreationDateQuery{
CreationDate: nowPB,
Method: objpb.TimestampQueryMethod_TIMESTAMP_QUERY_METHOD_LESS,
},
},
},
want: domain.SessionCreationDateFilter{Op: database.NumberOperationLessThan, Date: now},
},
{
name: "CreationDateQuery with LESS_OR_EQUALS method",
input: &session_grpc.SearchQuery{
Query: &session_grpc.SearchQuery_CreationDateQuery{
CreationDateQuery: &session_grpc.CreationDateQuery{
CreationDate: nowPB,
Method: objpb.TimestampQueryMethod_TIMESTAMP_QUERY_METHOD_LESS_OR_EQUALS,
},
},
},
want: domain.SessionCreationDateFilter{Op: database.NumberOperationLessThanOrEqual, Date: now},
},
{
name: "CreatorQuery with nil ID uses userID from context",
input: &session_grpc.SearchQuery{
Query: &session_grpc.SearchQuery_CreatorQuery{
CreatorQuery: &session_grpc.CreatorQuery{},
},
},
want: domain.SessionCreatorFilter{ID: userID},
},
{
name: "CreatorQuery with empty ID returns error",
input: &session_grpc.SearchQuery{
Query: &session_grpc.SearchQuery_CreatorQuery{
CreatorQuery: &session_grpc.CreatorQuery{Id: &emptyStr},
},
},
wantErr: zerrors.ThrowInvalidArgument(nil, "DOM-x8n24uh", "List.Query.Invalid"),
},
{
name: "CreatorQuery with valid ID uses it",
input: &session_grpc.SearchQuery{
Query: &session_grpc.SearchQuery_CreatorQuery{
CreatorQuery: &session_grpc.CreatorQuery{Id: &creatorID},
},
},
want: domain.SessionCreatorFilter{ID: creatorID},
},
{
name: "UserAgentQuery with nil FingerprintID uses agentID in context",
input: &session_grpc.SearchQuery{
Query: &session_grpc.SearchQuery_UserAgentQuery{
UserAgentQuery: &session_grpc.UserAgentQuery{},
},
},
want: domain.SessionUserAgentFilter{FingerprintID: agentID},
},
{
name: "UserAgentQuery with empty FingerprintID returns error",
input: &session_grpc.SearchQuery{
Query: &session_grpc.SearchQuery_UserAgentQuery{
UserAgentQuery: &session_grpc.UserAgentQuery{FingerprintId: &emptyStr},
},
},
wantErr: zerrors.ThrowInvalidArgument(nil, "DOM-x8n23uh", "List.Query.Invalid"),
},
{
name: "UserAgentQuery with valid FingerprintID uses it",
input: &session_grpc.SearchQuery{
Query: &session_grpc.SearchQuery_UserAgentQuery{
UserAgentQuery: &session_grpc.UserAgentQuery{FingerprintId: &fingerprintID},
},
},
want: domain.SessionUserAgentFilter{FingerprintID: fingerprintID},
},
{
name: "ExpirationDateQuery with EQUALS method",
input: &session_grpc.SearchQuery{
Query: &session_grpc.SearchQuery_ExpirationDateQuery{
ExpirationDateQuery: &session_grpc.ExpirationDateQuery{
ExpirationDate: nowPB,
Method: objpb.TimestampQueryMethod_TIMESTAMP_QUERY_METHOD_EQUALS,
},
},
},
want: domain.SessionExpirationDateFilter{Op: database.NumberOperationEqual, Date: now},
},
{
name: "ExpirationDateQuery with GREATER method",
input: &session_grpc.SearchQuery{
Query: &session_grpc.SearchQuery_ExpirationDateQuery{
ExpirationDateQuery: &session_grpc.ExpirationDateQuery{
ExpirationDate: nowPB,
Method: objpb.TimestampQueryMethod_TIMESTAMP_QUERY_METHOD_GREATER,
},
},
},
want: domain.SessionExpirationDateFilter{Op: database.NumberOperationGreaterThan, Date: now},
},
{
name: "unknown query type returns error",
input: &session_grpc.SearchQuery{},
wantErr: zerrors.ThrowInvalidArgumentf(nil, "CONV-Cz5s3t", "session search query %T not implemented", nil),
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ctx := authz.NewMockContextWithAgent("instance-1", "org-1", userID, agentID)
got, err := searchQueryGRPCToDomain(ctx, tc.input)
assert.ErrorIs(t, err, tc.wantErr)
assert.Equal(t, tc.want, got)
})
}
}
func TestSearchQueriesGRPCToDomain(t *testing.T) {
t.Parallel()
emptyStr := ""
tests := []struct {
name string
input []*session_grpc.SearchQuery
want []domain.SessionFilter
wantErr bool
}{
{
name: "nil slice returns nil",
input: nil,
want: nil,
},
{
name: "empty slice returns nil",
input: []*session_grpc.SearchQuery{},
want: nil,
},
{
name: "valid queries are converted to filters",
input: []*session_grpc.SearchQuery{
{Query: &session_grpc.SearchQuery_IdsQuery{IdsQuery: &session_grpc.IDsQuery{Ids: []string{"id1"}}}},
{Query: &session_grpc.SearchQuery_UserIdQuery{UserIdQuery: &session_grpc.UserIDQuery{Id: "user-1"}}},
},
want: []domain.SessionFilter{
domain.SessionIDsFilter{IDs: []string{"id1"}},
domain.SessionUserIDFilter{UserID: "user-1"},
},
},
{
name: "invalid query inside list propagates error",
input: []*session_grpc.SearchQuery{
{
Query: &session_grpc.SearchQuery_CreatorQuery{
CreatorQuery: &session_grpc.CreatorQuery{Id: &emptyStr},
},
},
},
wantErr: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ctx := authz.NewMockContext("instance-1", "org-1", "user-1")
got, err := searchQueriesGRPCToDomain(ctx, tc.input)
if tc.wantErr {
assert.Error(t, err)
assert.Nil(t, got)
return
}
assert.NoError(t, err)
assert.Equal(t, tc.want, got)
})
}
}
func TestListSessionsRequestGRPCToDomain(t *testing.T) {
t.Parallel()
emptyStr := ""
tests := []struct {
name string
input *session_grpc.ListSessionsRequest
want *domain.ListSessionsRequest
wantErr bool
}{
{
name: "nil request returns empty domain request",
input: nil,
want: &domain.ListSessionsRequest{
SortColumn: domain.SessionSortColumnUnspecified,
},
},
{
name: "invalid query propagates error",
input: &session_grpc.ListSessionsRequest{
Queries: []*session_grpc.SearchQuery{
{
Query: &session_grpc.SearchQuery_CreatorQuery{
CreatorQuery: &session_grpc.CreatorQuery{Id: &emptyStr},
},
},
},
},
wantErr: true,
},
{
name: "SESSION_FIELD_NAME_CREATION_DATE maps to SessionSortColumnCreationDate",
input: &session_grpc.ListSessionsRequest{
SortingColumn: session_grpc.SessionFieldName_SESSION_FIELD_NAME_CREATION_DATE,
},
want: &domain.ListSessionsRequest{
SortColumn: domain.SessionSortColumnCreationDate,
},
},
{
name: "SESSION_FIELD_NAME_UNSPECIFIED maps to SessionSortColumnUnspecified",
input: &session_grpc.ListSessionsRequest{
SortingColumn: session_grpc.SessionFieldName_SESSION_FIELD_NAME_UNSPECIFIED,
},
want: &domain.ListSessionsRequest{
SortColumn: domain.SessionSortColumnUnspecified,
},
},
{
name: "query limit, offset and ascending are mapped",
input: &session_grpc.ListSessionsRequest{
Query: &objv2.ListQuery{Limit: 20, Offset: 5, Asc: true},
},
want: &domain.ListSessionsRequest{
SortColumn: domain.SessionSortColumnUnspecified,
Limit: 20,
Offset: 5,
Ascending: true,
},
},
{
name: "valid queries are included in result",
input: &session_grpc.ListSessionsRequest{
Queries: []*session_grpc.SearchQuery{
{Query: &session_grpc.SearchQuery_UserIdQuery{UserIdQuery: &session_grpc.UserIDQuery{Id: "user-1"}}},
},
},
want: &domain.ListSessionsRequest{
SortColumn: domain.SessionSortColumnUnspecified,
Filters: []domain.SessionFilter{domain.SessionUserIDFilter{UserID: "user-1"}},
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ctx := authz.NewMockContext("instance-1", "org-1", "user-2")
got, err := ListSessionsRequestGRPCToDomain(ctx, tc.input)
if tc.wantErr {
assert.Error(t, err)
assert.Nil(t, got)
return
}
assert.NoError(t, err)
assert.Equal(t, tc.want, got)
})
}
}
func TestDomainSessionListToGRPCResponse(t *testing.T) {
t.Parallel()
t.Run("empty slice returns empty response slice", func(t *testing.T) {
t.Parallel()
got := DomainSessionListToGRPCResponse([]*domain.Session{})
assert.Empty(t, got)
})
t.Run("each session is converted", func(t *testing.T) {
t.Parallel()
sessions := []*domain.Session{
{ID: "sess-1"},
{ID: "sess-2"},
}
got := DomainSessionListToGRPCResponse(sessions)
assert.Len(t, got, 2)
assert.Equal(t, "sess-1", got[0].Id)
assert.Equal(t, "sess-2", got[1].Id)
})
}
func TestDomainSessionToGRPCResponse(t *testing.T) {
t.Parallel()
createdAt := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC)
updatedAt := time.Date(2026, 1, 2, 10, 0, 0, 0, time.UTC)
expiration := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
t.Run("basic fields are mapped", func(t *testing.T) {
t.Parallel()
lsr := &domain.Session{
ID: "sess-abc",
CreatedAt: createdAt,
UpdatedAt: updatedAt,
}
got := DomainSessionToGRPCResponse(lsr)
assert.Equal(t, "sess-abc", got.Id)
assert.Equal(t, timestamppb.New(createdAt), got.CreationDate)
assert.Equal(t, timestamppb.New(updatedAt), got.ChangeDate)
assert.EqualValues(t, 0, got.Sequence)
assert.Nil(t, got.ExpirationDate)
assert.Nil(t, got.Metadata)
})
t.Run("expiration is set when non-zero", func(t *testing.T) {
t.Parallel()
lsr := &domain.Session{Expiration: expiration}
got := DomainSessionToGRPCResponse(lsr)
assert.Equal(t, timestamppb.New(expiration), got.ExpirationDate)
})
t.Run("zero expiration is not set", func(t *testing.T) {
t.Parallel()
lsr := &domain.Session{}
got := DomainSessionToGRPCResponse(lsr)
assert.Nil(t, got.ExpirationDate)
})
t.Run("metadata is mapped to key-value pairs", func(t *testing.T) {
t.Parallel()
lsr := &domain.Session{
Metadata: []*domain.SessionMetadata{
{Metadata: domain.Metadata{Key: "k1", Value: []byte("v1")}},
{Metadata: domain.Metadata{Key: "k2", Value: []byte("v2")}},
},
}
got := DomainSessionToGRPCResponse(lsr)
assert.Equal(t, map[string][]byte{"k1": []byte("v1"), "k2": []byte("v2")}, got.Metadata)
})
t.Run("empty metadata is not set", func(t *testing.T) {
t.Parallel()
lsr := &domain.Session{
Metadata: []*domain.SessionMetadata{},
}
got := DomainSessionToGRPCResponse(lsr)
assert.Nil(t, got.Metadata)
})
}
func TestDomainFactorsToGRPC(t *testing.T) {
t.Parallel()
verifiedAt := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
verifiedAtPB := timestamppb.New(verifiedAt)
t.Run("no user factor returns nil", func(t *testing.T) {
t.Parallel()
got := domainFactorsToGRPC(&domain.Session{Factors: domain.SessionFactors{}})
assert.Nil(t, got)
})
t.Run("no factors returns nil", func(t *testing.T) {
t.Parallel()
got := domainFactorsToGRPC(&domain.Session{})
assert.Nil(t, got)
})
t.Run("only user factor maps User field with nil user", func(t *testing.T) {
t.Parallel()
factors := domain.SessionFactors{
&domain.SessionFactorUser{UserID: "user-1", LastVerifiedAt: verifiedAt},
}
got := domainFactorsToGRPC(&domain.Session{Factors: factors})
assert.NotNil(t, got)
assert.Equal(t, verifiedAtPB, got.User.VerifiedAt)
assert.Equal(t, "user-1", got.User.Id)
assert.Zero(t, got.User.LoginName)
assert.Zero(t, got.User.OrganizationId)
assert.Zero(t, got.User.DisplayName)
assert.Nil(t, got.Password)
assert.Nil(t, got.WebAuthN)
assert.Nil(t, got.Intent)
assert.Nil(t, got.Totp)
assert.Nil(t, got.OtpSms)
assert.Nil(t, got.OtpEmail)
assert.Nil(t, got.RecoveryCode)
})
t.Run("only user factor maps User field", func(t *testing.T) {
t.Parallel()
factors := domain.SessionFactors{
&domain.SessionFactorUser{UserID: "user-1", LastVerifiedAt: verifiedAt},
}
got := domainFactorsToGRPC(&domain.Session{
Factors: factors,
UserPreferredLoginName: "preferred ln",
UserOrganizationID: gu.Ptr("org-1"),
UserDisplayName: gu.Ptr("displayed name"),
})
assert.NotNil(t, got)
assert.Equal(t, "user-1", got.User.Id)
assert.Equal(t, verifiedAtPB, got.User.VerifiedAt)
assert.Equal(t, "preferred ln", got.User.LoginName)
assert.Equal(t, "org-1", got.User.OrganizationId)
assert.Equal(t, "displayed name", got.User.DisplayName)
assert.Nil(t, got.Password)
assert.Nil(t, got.WebAuthN)
assert.Nil(t, got.Intent)
assert.Nil(t, got.Totp)
assert.Nil(t, got.OtpSms)
assert.Nil(t, got.OtpEmail)
assert.Nil(t, got.RecoveryCode)
})
t.Run("all factors are mapped", func(t *testing.T) {
t.Parallel()
factors := domain.SessionFactors{
&domain.SessionFactorUser{UserID: "user-1", LastVerifiedAt: verifiedAt},
&domain.SessionFactorPassword{LastVerifiedAt: verifiedAt},
&domain.SessionFactorPasskey{LastVerifiedAt: verifiedAt, UserVerified: true},
&domain.SessionFactorIdentityProviderIntent{LastVerifiedAt: verifiedAt},
&domain.SessionFactorTOTP{LastVerifiedAt: verifiedAt},
&domain.SessionFactorOTPSMS{LastVerifiedAt: verifiedAt},
&domain.SessionFactorOTPEmail{LastVerifiedAt: verifiedAt},
&domain.SessionFactorRecoveryCode{LastVerifiedAt: verifiedAt},
}
session := &domain.Session{
Factors: factors,
UserPreferredLoginName: "preferred ln",
UserOrganizationID: gu.Ptr("org-1"),
UserDisplayName: gu.Ptr("displayed name"),
}
got := domainFactorsToGRPC(session)
assert.NotNil(t, got)
assert.Equal(t, "user-1", got.User.Id)
assert.Equal(t, verifiedAtPB, got.User.VerifiedAt)
assert.Equal(t, "preferred ln", got.User.LoginName)
assert.Equal(t, "org-1", got.User.OrganizationId)
assert.Equal(t, "displayed name", got.User.DisplayName)
assert.Equal(t, verifiedAtPB, got.User.VerifiedAt)
assert.Equal(t, verifiedAtPB, got.Password.VerifiedAt)
assert.Equal(t, verifiedAtPB, got.WebAuthN.VerifiedAt)
assert.True(t, got.WebAuthN.UserVerified)
assert.Equal(t, verifiedAtPB, got.Intent.VerifiedAt)
assert.Equal(t, verifiedAtPB, got.Totp.VerifiedAt)
assert.Equal(t, verifiedAtPB, got.OtpSms.VerifiedAt)
assert.Equal(t, verifiedAtPB, got.OtpEmail.VerifiedAt)
assert.Equal(t, verifiedAtPB, got.RecoveryCode.VerifiedAt)
})
}
func TestDomainUserAgentToGRPC(t *testing.T) {
t.Parallel()
fpID := "fp-abc"
desc := "Chrome on macOS"
ip := net.ParseIP("192.168.1.1")
ipStr := ip.String()
t.Run("nil user agent returns nil", func(t *testing.T) {
t.Parallel()
got := domainUserAgentToGRPC(nil)
assert.Nil(t, got)
})
t.Run("empty user agent returns empty proto", func(t *testing.T) {
t.Parallel()
got := domainUserAgentToGRPC(&domain.SessionUserAgent{})
assert.NotNil(t, got)
assert.Nil(t, got.FingerprintId)
assert.Nil(t, got.Description)
assert.Nil(t, got.Ip)
assert.Nil(t, got.Header)
})
t.Run("fingerprint ID is mapped", func(t *testing.T) {
t.Parallel()
got := domainUserAgentToGRPC(&domain.SessionUserAgent{FingerprintID: &fpID})
assert.Equal(t, &fpID, got.FingerprintId)
})
t.Run("description is mapped", func(t *testing.T) {
t.Parallel()
got := domainUserAgentToGRPC(&domain.SessionUserAgent{Description: &desc})
assert.Equal(t, &desc, got.Description)
})
t.Run("IP is mapped as string", func(t *testing.T) {
t.Parallel()
got := domainUserAgentToGRPC(&domain.SessionUserAgent{IP: ip})
assert.Equal(t, &ipStr, got.Ip)
})
t.Run("headers are mapped", func(t *testing.T) {
t.Parallel()
ua := &domain.SessionUserAgent{
Header: http.Header{"Accept": []string{"application/json", "text/html"}},
}
got := domainUserAgentToGRPC(ua)
assert.Equal(t, []string{"application/json", "text/html"}, got.Header["Accept"].Values)
})
}
+27 -2
View File
@@ -6,12 +6,37 @@ import (
"connectrpc.com/connect"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/zitadel/zitadel/backend/v3/api/session/v2/convert"
"github.com/zitadel/zitadel/backend/v3/domain"
"github.com/zitadel/zitadel/backend/v3/storage/database/repository"
"github.com/zitadel/zitadel/pkg/grpc/object/v2"
"github.com/zitadel/zitadel/pkg/grpc/session/v2"
object "github.com/zitadel/zitadel/pkg/grpc/object/v2"
session "github.com/zitadel/zitadel/pkg/grpc/session/v2"
)
func ListSessions(ctx context.Context, request *connect.Request[session.ListSessionsRequest]) (*connect.Response[session.ListSessionsResponse], error) {
req, err := convert.ListSessionsRequestGRPCToDomain(ctx, request.Msg)
if err != nil {
return nil, err
}
q := domain.NewListSessionsQuery(req)
err = domain.Invoke(ctx, q, domain.WithSessionRepo(repository.SessionRepository()))
if err != nil {
return nil, err
}
sessions := convert.DomainSessionListToGRPCResponse(q.Result())
return connect.NewResponse(&session.ListSessionsResponse{
Sessions: sessions,
Details: &object.ListDetails{
TotalResult: uint64(len(sessions)),
// TODO(IAM-Marco): Put something meaningful once we have a reliable timestamp from DB
Timestamp: timestamppb.Now(),
},
}), nil
}
func DeleteSession(ctx context.Context, request *connect.Request[session.DeleteSessionRequest]) (*connect.Response[session.DeleteSessionResponse], error) {
sessionDeleteCmd := domain.NewDeleteSessionCommand(request.Msg.GetSessionId(), request.Msg.GetSessionToken(), true)
+32
View File
@@ -0,0 +1,32 @@
package domain
import (
"context"
"github.com/zitadel/zitadel/backend/v3/storage/database"
)
type administratorRoleColumns interface {
PrimaryKeyColumns() []database.Column
InstanceIDColumn() database.Column
RoleNameColumn() database.Column
PermissionColumn() database.Column
}
type administratorRoleConditions interface {
PrimaryKeyCondition(instanceID, roleName, permission string) database.Condition
InstanceIDCondition(instanceID string) database.Condition
RoleNameCondition(op database.TextOperation, name string) database.Condition
PermissionCondition(op database.TextOperation, permission string) database.Condition
}
//go:generate mockgen -typed -package domainmock -destination ./mock/administrator_role.mock.go . AdministratorRoleRepository
type AdministratorRoleRepository interface {
Repository
administratorRoleColumns
administratorRoleConditions
AddPermissions(ctx context.Context, client database.QueryExecutor, instanceID, role string, permissions ...string) (int64, error)
RemovePermissions(ctx context.Context, client database.QueryExecutor, instanceID, role string, permissionsToRemove ...string) (int64, error)
}
+1
View File
@@ -97,6 +97,7 @@ type JWT struct {
Issuer string `json:"issuer,omitempty"`
KeysEndpoint string `json:"keysEndpoint,omitempty"`
HeaderName string `json:"headerName,omitempty"`
Audience string `json:"audience,omitempty"`
}
type IDPJWT struct {
@@ -0,0 +1,434 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/zitadel/zitadel/backend/v3/domain (interfaces: AdministratorRoleRepository)
//
// Generated by this command:
//
// mockgen -typed -package domainmock -destination ./mock/administrator_role.mock.go . AdministratorRoleRepository
//
// Package domainmock is a generated GoMock package.
package domainmock
import (
context "context"
reflect "reflect"
database "github.com/zitadel/zitadel/backend/v3/storage/database"
gomock "go.uber.org/mock/gomock"
)
// MockAdministratorRoleRepository is a mock of AdministratorRoleRepository interface.
type MockAdministratorRoleRepository struct {
ctrl *gomock.Controller
recorder *MockAdministratorRoleRepositoryMockRecorder
isgomock struct{}
}
// MockAdministratorRoleRepositoryMockRecorder is the mock recorder for MockAdministratorRoleRepository.
type MockAdministratorRoleRepositoryMockRecorder struct {
mock *MockAdministratorRoleRepository
}
// NewMockAdministratorRoleRepository creates a new mock instance.
func NewMockAdministratorRoleRepository(ctrl *gomock.Controller) *MockAdministratorRoleRepository {
mock := &MockAdministratorRoleRepository{ctrl: ctrl}
mock.recorder = &MockAdministratorRoleRepositoryMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockAdministratorRoleRepository) EXPECT() *MockAdministratorRoleRepositoryMockRecorder {
return m.recorder
}
// AddPermissions mocks base method.
func (m *MockAdministratorRoleRepository) AddPermissions(ctx context.Context, client database.QueryExecutor, instanceID, role string, permissions ...string) (int64, error) {
m.ctrl.T.Helper()
varargs := []any{ctx, client, instanceID, role}
for _, a := range permissions {
varargs = append(varargs, a)
}
ret := m.ctrl.Call(m, "AddPermissions", varargs...)
ret0, _ := ret[0].(int64)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// AddPermissions indicates an expected call of AddPermissions.
func (mr *MockAdministratorRoleRepositoryMockRecorder) AddPermissions(ctx, client, instanceID, role any, permissions ...any) *MockAdministratorRoleRepositoryAddPermissionsCall {
mr.mock.ctrl.T.Helper()
varargs := append([]any{ctx, client, instanceID, role}, permissions...)
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddPermissions", reflect.TypeOf((*MockAdministratorRoleRepository)(nil).AddPermissions), varargs...)
return &MockAdministratorRoleRepositoryAddPermissionsCall{Call: call}
}
// MockAdministratorRoleRepositoryAddPermissionsCall wrap *gomock.Call
type MockAdministratorRoleRepositoryAddPermissionsCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockAdministratorRoleRepositoryAddPermissionsCall) Return(arg0 int64, arg1 error) *MockAdministratorRoleRepositoryAddPermissionsCall {
c.Call = c.Call.Return(arg0, arg1)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockAdministratorRoleRepositoryAddPermissionsCall) Do(f func(context.Context, database.QueryExecutor, string, string, ...string) (int64, error)) *MockAdministratorRoleRepositoryAddPermissionsCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockAdministratorRoleRepositoryAddPermissionsCall) DoAndReturn(f func(context.Context, database.QueryExecutor, string, string, ...string) (int64, error)) *MockAdministratorRoleRepositoryAddPermissionsCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// InstanceIDColumn mocks base method.
func (m *MockAdministratorRoleRepository) InstanceIDColumn() database.Column {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "InstanceIDColumn")
ret0, _ := ret[0].(database.Column)
return ret0
}
// InstanceIDColumn indicates an expected call of InstanceIDColumn.
func (mr *MockAdministratorRoleRepositoryMockRecorder) InstanceIDColumn() *MockAdministratorRoleRepositoryInstanceIDColumnCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InstanceIDColumn", reflect.TypeOf((*MockAdministratorRoleRepository)(nil).InstanceIDColumn))
return &MockAdministratorRoleRepositoryInstanceIDColumnCall{Call: call}
}
// MockAdministratorRoleRepositoryInstanceIDColumnCall wrap *gomock.Call
type MockAdministratorRoleRepositoryInstanceIDColumnCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockAdministratorRoleRepositoryInstanceIDColumnCall) Return(arg0 database.Column) *MockAdministratorRoleRepositoryInstanceIDColumnCall {
c.Call = c.Call.Return(arg0)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockAdministratorRoleRepositoryInstanceIDColumnCall) Do(f func() database.Column) *MockAdministratorRoleRepositoryInstanceIDColumnCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockAdministratorRoleRepositoryInstanceIDColumnCall) DoAndReturn(f func() database.Column) *MockAdministratorRoleRepositoryInstanceIDColumnCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// InstanceIDCondition mocks base method.
func (m *MockAdministratorRoleRepository) InstanceIDCondition(instanceID string) database.Condition {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "InstanceIDCondition", instanceID)
ret0, _ := ret[0].(database.Condition)
return ret0
}
// InstanceIDCondition indicates an expected call of InstanceIDCondition.
func (mr *MockAdministratorRoleRepositoryMockRecorder) InstanceIDCondition(instanceID any) *MockAdministratorRoleRepositoryInstanceIDConditionCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InstanceIDCondition", reflect.TypeOf((*MockAdministratorRoleRepository)(nil).InstanceIDCondition), instanceID)
return &MockAdministratorRoleRepositoryInstanceIDConditionCall{Call: call}
}
// MockAdministratorRoleRepositoryInstanceIDConditionCall wrap *gomock.Call
type MockAdministratorRoleRepositoryInstanceIDConditionCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockAdministratorRoleRepositoryInstanceIDConditionCall) Return(arg0 database.Condition) *MockAdministratorRoleRepositoryInstanceIDConditionCall {
c.Call = c.Call.Return(arg0)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockAdministratorRoleRepositoryInstanceIDConditionCall) Do(f func(string) database.Condition) *MockAdministratorRoleRepositoryInstanceIDConditionCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockAdministratorRoleRepositoryInstanceIDConditionCall) DoAndReturn(f func(string) database.Condition) *MockAdministratorRoleRepositoryInstanceIDConditionCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// PermissionColumn mocks base method.
func (m *MockAdministratorRoleRepository) PermissionColumn() database.Column {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "PermissionColumn")
ret0, _ := ret[0].(database.Column)
return ret0
}
// PermissionColumn indicates an expected call of PermissionColumn.
func (mr *MockAdministratorRoleRepositoryMockRecorder) PermissionColumn() *MockAdministratorRoleRepositoryPermissionColumnCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PermissionColumn", reflect.TypeOf((*MockAdministratorRoleRepository)(nil).PermissionColumn))
return &MockAdministratorRoleRepositoryPermissionColumnCall{Call: call}
}
// MockAdministratorRoleRepositoryPermissionColumnCall wrap *gomock.Call
type MockAdministratorRoleRepositoryPermissionColumnCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockAdministratorRoleRepositoryPermissionColumnCall) Return(arg0 database.Column) *MockAdministratorRoleRepositoryPermissionColumnCall {
c.Call = c.Call.Return(arg0)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockAdministratorRoleRepositoryPermissionColumnCall) Do(f func() database.Column) *MockAdministratorRoleRepositoryPermissionColumnCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockAdministratorRoleRepositoryPermissionColumnCall) DoAndReturn(f func() database.Column) *MockAdministratorRoleRepositoryPermissionColumnCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// PermissionCondition mocks base method.
func (m *MockAdministratorRoleRepository) PermissionCondition(op database.TextOperation, permission string) database.Condition {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "PermissionCondition", op, permission)
ret0, _ := ret[0].(database.Condition)
return ret0
}
// PermissionCondition indicates an expected call of PermissionCondition.
func (mr *MockAdministratorRoleRepositoryMockRecorder) PermissionCondition(op, permission any) *MockAdministratorRoleRepositoryPermissionConditionCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PermissionCondition", reflect.TypeOf((*MockAdministratorRoleRepository)(nil).PermissionCondition), op, permission)
return &MockAdministratorRoleRepositoryPermissionConditionCall{Call: call}
}
// MockAdministratorRoleRepositoryPermissionConditionCall wrap *gomock.Call
type MockAdministratorRoleRepositoryPermissionConditionCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockAdministratorRoleRepositoryPermissionConditionCall) Return(arg0 database.Condition) *MockAdministratorRoleRepositoryPermissionConditionCall {
c.Call = c.Call.Return(arg0)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockAdministratorRoleRepositoryPermissionConditionCall) Do(f func(database.TextOperation, string) database.Condition) *MockAdministratorRoleRepositoryPermissionConditionCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockAdministratorRoleRepositoryPermissionConditionCall) DoAndReturn(f func(database.TextOperation, string) database.Condition) *MockAdministratorRoleRepositoryPermissionConditionCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// PrimaryKeyColumns mocks base method.
func (m *MockAdministratorRoleRepository) PrimaryKeyColumns() []database.Column {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "PrimaryKeyColumns")
ret0, _ := ret[0].([]database.Column)
return ret0
}
// PrimaryKeyColumns indicates an expected call of PrimaryKeyColumns.
func (mr *MockAdministratorRoleRepositoryMockRecorder) PrimaryKeyColumns() *MockAdministratorRoleRepositoryPrimaryKeyColumnsCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PrimaryKeyColumns", reflect.TypeOf((*MockAdministratorRoleRepository)(nil).PrimaryKeyColumns))
return &MockAdministratorRoleRepositoryPrimaryKeyColumnsCall{Call: call}
}
// MockAdministratorRoleRepositoryPrimaryKeyColumnsCall wrap *gomock.Call
type MockAdministratorRoleRepositoryPrimaryKeyColumnsCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockAdministratorRoleRepositoryPrimaryKeyColumnsCall) Return(arg0 []database.Column) *MockAdministratorRoleRepositoryPrimaryKeyColumnsCall {
c.Call = c.Call.Return(arg0)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockAdministratorRoleRepositoryPrimaryKeyColumnsCall) Do(f func() []database.Column) *MockAdministratorRoleRepositoryPrimaryKeyColumnsCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockAdministratorRoleRepositoryPrimaryKeyColumnsCall) DoAndReturn(f func() []database.Column) *MockAdministratorRoleRepositoryPrimaryKeyColumnsCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// PrimaryKeyCondition mocks base method.
func (m *MockAdministratorRoleRepository) PrimaryKeyCondition(instanceID, roleName, permission string) database.Condition {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "PrimaryKeyCondition", instanceID, roleName, permission)
ret0, _ := ret[0].(database.Condition)
return ret0
}
// PrimaryKeyCondition indicates an expected call of PrimaryKeyCondition.
func (mr *MockAdministratorRoleRepositoryMockRecorder) PrimaryKeyCondition(instanceID, roleName, permission any) *MockAdministratorRoleRepositoryPrimaryKeyConditionCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PrimaryKeyCondition", reflect.TypeOf((*MockAdministratorRoleRepository)(nil).PrimaryKeyCondition), instanceID, roleName, permission)
return &MockAdministratorRoleRepositoryPrimaryKeyConditionCall{Call: call}
}
// MockAdministratorRoleRepositoryPrimaryKeyConditionCall wrap *gomock.Call
type MockAdministratorRoleRepositoryPrimaryKeyConditionCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockAdministratorRoleRepositoryPrimaryKeyConditionCall) Return(arg0 database.Condition) *MockAdministratorRoleRepositoryPrimaryKeyConditionCall {
c.Call = c.Call.Return(arg0)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockAdministratorRoleRepositoryPrimaryKeyConditionCall) Do(f func(string, string, string) database.Condition) *MockAdministratorRoleRepositoryPrimaryKeyConditionCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockAdministratorRoleRepositoryPrimaryKeyConditionCall) DoAndReturn(f func(string, string, string) database.Condition) *MockAdministratorRoleRepositoryPrimaryKeyConditionCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// RemovePermissions mocks base method.
func (m *MockAdministratorRoleRepository) RemovePermissions(ctx context.Context, client database.QueryExecutor, instanceID, role string, permissionsToRemove ...string) (int64, error) {
m.ctrl.T.Helper()
varargs := []any{ctx, client, instanceID, role}
for _, a := range permissionsToRemove {
varargs = append(varargs, a)
}
ret := m.ctrl.Call(m, "RemovePermissions", varargs...)
ret0, _ := ret[0].(int64)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// RemovePermissions indicates an expected call of RemovePermissions.
func (mr *MockAdministratorRoleRepositoryMockRecorder) RemovePermissions(ctx, client, instanceID, role any, permissionsToRemove ...any) *MockAdministratorRoleRepositoryRemovePermissionsCall {
mr.mock.ctrl.T.Helper()
varargs := append([]any{ctx, client, instanceID, role}, permissionsToRemove...)
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemovePermissions", reflect.TypeOf((*MockAdministratorRoleRepository)(nil).RemovePermissions), varargs...)
return &MockAdministratorRoleRepositoryRemovePermissionsCall{Call: call}
}
// MockAdministratorRoleRepositoryRemovePermissionsCall wrap *gomock.Call
type MockAdministratorRoleRepositoryRemovePermissionsCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockAdministratorRoleRepositoryRemovePermissionsCall) Return(arg0 int64, arg1 error) *MockAdministratorRoleRepositoryRemovePermissionsCall {
c.Call = c.Call.Return(arg0, arg1)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockAdministratorRoleRepositoryRemovePermissionsCall) Do(f func(context.Context, database.QueryExecutor, string, string, ...string) (int64, error)) *MockAdministratorRoleRepositoryRemovePermissionsCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockAdministratorRoleRepositoryRemovePermissionsCall) DoAndReturn(f func(context.Context, database.QueryExecutor, string, string, ...string) (int64, error)) *MockAdministratorRoleRepositoryRemovePermissionsCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// RoleNameColumn mocks base method.
func (m *MockAdministratorRoleRepository) RoleNameColumn() database.Column {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "RoleNameColumn")
ret0, _ := ret[0].(database.Column)
return ret0
}
// RoleNameColumn indicates an expected call of RoleNameColumn.
func (mr *MockAdministratorRoleRepositoryMockRecorder) RoleNameColumn() *MockAdministratorRoleRepositoryRoleNameColumnCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RoleNameColumn", reflect.TypeOf((*MockAdministratorRoleRepository)(nil).RoleNameColumn))
return &MockAdministratorRoleRepositoryRoleNameColumnCall{Call: call}
}
// MockAdministratorRoleRepositoryRoleNameColumnCall wrap *gomock.Call
type MockAdministratorRoleRepositoryRoleNameColumnCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockAdministratorRoleRepositoryRoleNameColumnCall) Return(arg0 database.Column) *MockAdministratorRoleRepositoryRoleNameColumnCall {
c.Call = c.Call.Return(arg0)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockAdministratorRoleRepositoryRoleNameColumnCall) Do(f func() database.Column) *MockAdministratorRoleRepositoryRoleNameColumnCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockAdministratorRoleRepositoryRoleNameColumnCall) DoAndReturn(f func() database.Column) *MockAdministratorRoleRepositoryRoleNameColumnCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// RoleNameCondition mocks base method.
func (m *MockAdministratorRoleRepository) RoleNameCondition(op database.TextOperation, name string) database.Condition {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "RoleNameCondition", op, name)
ret0, _ := ret[0].(database.Condition)
return ret0
}
// RoleNameCondition indicates an expected call of RoleNameCondition.
func (mr *MockAdministratorRoleRepositoryMockRecorder) RoleNameCondition(op, name any) *MockAdministratorRoleRepositoryRoleNameConditionCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RoleNameCondition", reflect.TypeOf((*MockAdministratorRoleRepository)(nil).RoleNameCondition), op, name)
return &MockAdministratorRoleRepositoryRoleNameConditionCall{Call: call}
}
// MockAdministratorRoleRepositoryRoleNameConditionCall wrap *gomock.Call
type MockAdministratorRoleRepositoryRoleNameConditionCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockAdministratorRoleRepositoryRoleNameConditionCall) Return(arg0 database.Condition) *MockAdministratorRoleRepositoryRoleNameConditionCall {
c.Call = c.Call.Return(arg0)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockAdministratorRoleRepositoryRoleNameConditionCall) Do(f func(database.TextOperation, string) database.Condition) *MockAdministratorRoleRepositoryRoleNameConditionCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockAdministratorRoleRepositoryRoleNameConditionCall) DoAndReturn(f func(database.TextOperation, string) database.Condition) *MockAdministratorRoleRepositoryRoleNameConditionCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
+38
View File
@@ -741,6 +741,44 @@ func (c *MockSessionRepositoryListCall) DoAndReturn(f func(context.Context, data
return c
}
// LoadUserData mocks base method.
func (m *MockSessionRepository) LoadUserData() domain.SessionRepository {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "LoadUserData")
ret0, _ := ret[0].(domain.SessionRepository)
return ret0
}
// LoadUserData indicates an expected call of LoadUserData.
func (mr *MockSessionRepositoryMockRecorder) LoadUserData() *MockSessionRepositoryLoadUserDataCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadUserData", reflect.TypeOf((*MockSessionRepository)(nil).LoadUserData))
return &MockSessionRepositoryLoadUserDataCall{Call: call}
}
// MockSessionRepositoryLoadUserDataCall wrap *gomock.Call
type MockSessionRepositoryLoadUserDataCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockSessionRepositoryLoadUserDataCall) Return(arg0 domain.SessionRepository) *MockSessionRepositoryLoadUserDataCall {
c.Call = c.Call.Return(arg0)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockSessionRepositoryLoadUserDataCall) Do(f func() domain.SessionRepository) *MockSessionRepositoryLoadUserDataCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockSessionRepositoryLoadUserDataCall) DoAndReturn(f func() domain.SessionRepository) *MockSessionRepositoryLoadUserDataCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// MetadataConditions mocks base method.
func (m *MockSessionRepository) MetadataConditions() domain.SessionMetadataConditions {
m.ctrl.T.Helper()
+4
View File
@@ -46,3 +46,7 @@ func (s *SessionRepo) Update(ctx context.Context, client database.QueryExecutor,
func (s *SessionRepo) Delete(ctx context.Context, client database.QueryExecutor, condition database.Condition, permissionCondition database.Condition) (int64, time.Time, error) {
return s.mock.Delete(ctx, client, condition, permissionCondition)
}
func (s *SessionRepo) LoadUserData() domain.SessionRepository {
return s.mock.LoadUserData()
}
+7
View File
@@ -22,6 +22,10 @@ type Session struct {
Challenges SessionChallenges `json:"challenges,omitempty"`
Metadata []*SessionMetadata `json:"metadata,omitempty"`
UserAgent *SessionUserAgent `json:"userAgent,omitempty"`
UserOrganizationID *string `json:"organizationId,omitempty" db:"organization_id"`
UserDisplayName *string `json:"display_name" db:"display_name"`
UserPreferredLoginName string
}
//go:generate mockgen -typed -package domainmock -destination ./mock/session.mock.go . SessionRepository
@@ -51,6 +55,9 @@ type SessionRepository interface {
// In case the session was previously deleted, the method returns 0 and the previous deletion timestamp.
// If multiple sessions are deleted, the method returns the number of deleted sessions and no timestamp, as it cannot be determined which session's deletion timestamp to return.
Delete(ctx context.Context, client database.QueryExecutor, condition database.Condition, permissionCondition database.Condition) (int64, time.Time, error)
// LoadUserData loads user data associated to the given session.
// If called, the [Session.UserOrganizationID], [Session.UserDisplayName] and [Session.UserPreferredLoginName] fields will be populated on future calls to [SessionRepository.Get] or [SessionRepository.List].
LoadUserData() SessionRepository
}
// sessionColumns define all the columns of the session table.
+185
View File
@@ -0,0 +1,185 @@
package domain
import (
"context"
"time"
"github.com/zitadel/zitadel/backend/v3/storage/database"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/zerrors"
)
type SessionFilter interface {
sessionFilter()
}
// SessionIDsFilter filters sessions by one or more IDs.
type SessionIDsFilter struct{ IDs []string }
func (SessionIDsFilter) sessionFilter() {}
// SessionUserIDFilter filters sessions by the associated user's ID.
type SessionUserIDFilter struct{ UserID string }
func (SessionUserIDFilter) sessionFilter() {}
// SessionCreationDateFilter filters sessions by creation date.
type SessionCreationDateFilter struct {
Op database.NumberOperation
Date time.Time
}
func (SessionCreationDateFilter) sessionFilter() {}
// SessionCreatorFilter filters sessions by the creator's ID.
type SessionCreatorFilter struct{ ID string }
func (SessionCreatorFilter) sessionFilter() {}
// SessionUserAgentFilter filters sessions by user-agent fingerprint.
type SessionUserAgentFilter struct{ FingerprintID string }
func (SessionUserAgentFilter) sessionFilter() {}
// SessionExpirationDateFilter filters sessions by expiration date.
type SessionExpirationDateFilter struct {
Op database.NumberOperation
Date time.Time
}
func (SessionExpirationDateFilter) sessionFilter() {}
// SessionSortColumn determines the column used to sort the result set.
type SessionSortColumn int
const (
SessionSortColumnUnspecified SessionSortColumn = iota
SessionSortColumnCreationDate
)
// ListSessionsRequest holds all parameters for a ListSessions query.
type ListSessionsRequest struct {
Filters []SessionFilter
SortColumn SessionSortColumn
Ascending bool
Limit uint32
Offset uint32
}
// ListSessionsQuery implements [Querier] for listing sessions.
type ListSessionsQuery struct {
Request *ListSessionsRequest
sessions []*Session
}
// Result implements [Querier].
func (l *ListSessionsQuery) Result() []*Session { return l.sessions }
var _ Querier[[]*Session] = (*ListSessionsQuery)(nil)
// NewListSessionsQuery returns a new [ListSessionsQuery] for the given request.
func NewListSessionsQuery(req *ListSessionsRequest) *ListSessionsQuery {
return &ListSessionsQuery{Request: req}
}
// String implements [Querier].
func (*ListSessionsQuery) String() string { return "ListSessionsQuery" }
// Validate implements [Querier].
func (l *ListSessionsQuery) Validate(_ context.Context, _ *InvokeOpts) error {
return nil
}
// Execute implements [Querier].
func (l *ListSessionsQuery) Execute(ctx context.Context, opts *InvokeOpts) error {
sessionRepo := opts.sessionRepo.LoadUserData()
conds, err := l.Conditions(ctx, sessionRepo)
if err != nil {
return err
}
l.sessions, err = sessionRepo.List(ctx, opts.DB(),
database.WithCondition(conds),
l.Sorting(sessionRepo),
database.WithLimit(l.Request.Limit),
database.WithOffset(l.Request.Offset),
)
if err != nil {
return zerrors.ThrowInternal(err, "DOM-Yx8q2r", "Errors.Session.List")
}
return nil
}
func (l *ListSessionsQuery) permissionCondition(ctx context.Context, sessionRepo SessionRepository) database.Condition {
ctxData := authz.GetCtxData(ctx)
return database.Or(
sessionRepo.UserIDCondition(ctxData.UserID),
sessionRepo.UserAgentIDCondition(ctxData.AgentID),
sessionRepo.CreatorIDCondition(ctxData.UserID),
database.PermissionCheck(SessionReadPermission, false),
)
}
func (l *ListSessionsQuery) Conditions(ctx context.Context, sessionRepo SessionRepository) (database.Condition, error) {
conds := make([]database.Condition, 2, 2+len(l.Request.Filters))
conds[0] = sessionRepo.InstanceIDCondition(authz.GetInstance(ctx).InstanceID())
conds[1] = l.permissionCondition(ctx, sessionRepo)
for _, f := range l.Request.Filters {
cond, err := l.sessionFilterToCondition(sessionRepo, f)
if err != nil {
return nil, err
}
conds = append(conds, cond)
}
return database.And(conds...), nil
}
func (l *ListSessionsQuery) sessionFilterToCondition(repo SessionRepository, filter SessionFilter) (database.Condition, error) {
switch typedFilter := filter.(type) {
case SessionIDsFilter:
idConds := make([]database.Condition, len(typedFilter.IDs))
for i, id := range typedFilter.IDs {
idConds[i] = repo.IDCondition(id)
}
return database.Or(idConds...), nil
case SessionUserIDFilter:
return repo.UserIDCondition(typedFilter.UserID), nil
case SessionCreationDateFilter:
return repo.CreatedAtCondition(typedFilter.Op, typedFilter.Date), nil
case SessionCreatorFilter:
return repo.CreatorIDCondition(typedFilter.ID), nil
case SessionUserAgentFilter:
return repo.UserAgentIDCondition(typedFilter.FingerprintID), nil
case SessionExpirationDateFilter:
// Unlike ES side, we are also including NOT EQUAL. This is an impossible case though since gRPC has no value for it
expCond := repo.ExpirationCondition(typedFilter.Op, typedFilter.Date)
if typedFilter.Op == database.NumberOperationGreaterThan || typedFilter.Op == database.NumberOperationGreaterThanOrEqual {
// Include also nil expirations (i.e. non-expiring sessions)
return database.Or(expCond, database.IsNull(repo.ExpirationColumn())), nil
}
return expCond, nil
default:
return nil, zerrors.ThrowInvalidArgument(NewUnexpectedQueryTypeError(typedFilter), "DOM-Cz5s3t", "List.Query.Invalid")
}
}
// Sorting returns the QueryOption for ordering results.
func (l *ListSessionsQuery) Sorting(sessionRepo SessionRepository) database.QueryOption {
if l.Request.SortColumn != SessionSortColumnCreationDate {
return func(*database.QueryOpts) {}
}
direction := database.OrderDirectionDesc
if l.Request.Ascending {
direction = database.OrderDirectionAsc
}
return database.WithOrderBy(direction, sessionRepo.CreatedAtColumn())
}
+401
View File
@@ -0,0 +1,401 @@
package domain_test
import (
"errors"
"testing"
"time"
"github.com/stretchr/testify/assert"
"go.uber.org/mock/gomock"
"github.com/zitadel/zitadel/backend/v3/domain"
domainmock "github.com/zitadel/zitadel/backend/v3/domain/mock"
"github.com/zitadel/zitadel/backend/v3/storage/database"
"github.com/zitadel/zitadel/backend/v3/storage/database/dbmock"
noopdb "github.com/zitadel/zitadel/backend/v3/storage/database/dialect/noop"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/zerrors"
)
func TestListSessionsQuery_Validate(t *testing.T) {
t.Parallel()
// Given
q := domain.NewListSessionsQuery(nil)
// Test
err := q.Validate(t.Context(), nil)
// Verify
assert.NoError(t, err)
}
func TestListSessionsQuery_Sorting(t *testing.T) {
t.Parallel()
tt := []struct {
name string
request *domain.ListSessionsRequest
expectedSortingDirection database.OrderDirection
expectedOrderBy database.Columns
}{
{
name: "sort by creation date DESC",
request: &domain.ListSessionsRequest{
SortColumn: domain.SessionSortColumnCreationDate,
},
expectedSortingDirection: database.OrderDirectionDesc,
expectedOrderBy: database.Columns{database.NewColumn("sessions", "created_at")},
},
{
name: "sort by creation date ASC",
request: &domain.ListSessionsRequest{
SortColumn: domain.SessionSortColumnCreationDate,
Ascending: true,
},
expectedSortingDirection: database.OrderDirectionAsc,
expectedOrderBy: database.Columns{database.NewColumn("sessions", "created_at")},
},
{
name: "unspecified sorting column — no ordering",
request: &domain.ListSessionsRequest{
SortColumn: domain.SessionSortColumnUnspecified,
},
expectedSortingDirection: database.OrderDirectionAsc,
expectedOrderBy: nil,
},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// Given
ctrl := gomock.NewController(t)
mockRepo := domainmock.NewSessionRepo(ctrl)
q := domain.NewListSessionsQuery(tc.request)
opts := &database.QueryOpts{}
// Test
queryOpt := q.Sorting(mockRepo)
queryOpt(opts)
// Verify
assert.Equal(t, tc.expectedOrderBy, opts.OrderBy)
assert.Equal(t, tc.expectedSortingDirection, opts.Ordering)
})
}
}
func TestListSessionsQuery_Conditions(t *testing.T) {
t.Parallel()
const (
userID = "user-1"
agentID = "agent-fp-1"
)
now := time.Now()
defaultConds := func(repo *domainmock.SessionRepo) []database.Condition {
return []database.Condition{
repo.InstanceIDCondition("inst-1"),
database.Or(
repo.UserIDCondition(userID),
repo.UserAgentIDCondition(agentID),
repo.CreatorIDCondition(userID),
database.PermissionCheck(domain.SessionReadPermission, false),
),
}
}
appendToDefaultConditions := func(repo *domainmock.SessionRepo, conds database.Condition) database.Condition {
return database.And(
append(defaultConds(repo), conds)...,
)
}
tt := []struct {
name string
request *domain.ListSessionsRequest
expectedCond func(repo *domainmock.SessionRepo) database.Condition
expectedError error
}{
{
name: "no filters returns permission and instance id conditions",
request: &domain.ListSessionsRequest{},
expectedCond: func(repo *domainmock.SessionRepo) database.Condition {
return database.And(defaultConds(repo)...)
},
},
{
name: "SessionIDsFilter empty returns empty OR",
request: &domain.ListSessionsRequest{
Filters: []domain.SessionFilter{
domain.SessionIDsFilter{IDs: []string{}},
},
},
expectedCond: func(repo *domainmock.SessionRepo) database.Condition {
return appendToDefaultConditions(repo, database.Or())
},
},
{
name: "SessionIDsFilter with multiple IDs returns OR",
request: &domain.ListSessionsRequest{
Filters: []domain.SessionFilter{
domain.SessionIDsFilter{IDs: []string{"s-1", "s-2"}},
},
},
expectedCond: func(repo *domainmock.SessionRepo) database.Condition {
return appendToDefaultConditions(repo, database.Or(repo.IDCondition("s-1"), repo.IDCondition("s-2")))
},
},
{
name: "SessionUserIDFilter",
request: &domain.ListSessionsRequest{
Filters: []domain.SessionFilter{
domain.SessionUserIDFilter{UserID: "target-user"},
},
},
expectedCond: func(repo *domainmock.SessionRepo) database.Condition {
return database.And(
appendToDefaultConditions(repo, repo.UserIDCondition("target-user")),
)
},
},
{
name: "SessionCreationDateFilter",
request: &domain.ListSessionsRequest{
Filters: []domain.SessionFilter{
domain.SessionCreationDateFilter{Op: database.NumberOperationGreaterThan, Date: now},
},
},
expectedCond: func(repo *domainmock.SessionRepo) database.Condition {
return database.And(
appendToDefaultConditions(repo, repo.CreatedAtCondition(database.NumberOperationGreaterThan, now)),
)
},
},
{
name: "SessionCreatorFilter",
request: &domain.ListSessionsRequest{
Filters: []domain.SessionFilter{
domain.SessionCreatorFilter{ID: "other-creator"},
},
},
expectedCond: func(repo *domainmock.SessionRepo) database.Condition {
return database.And(
appendToDefaultConditions(repo, repo.CreatorIDCondition("other-creator")),
)
},
},
{
name: "SessionUserAgentFilter",
request: &domain.ListSessionsRequest{
Filters: []domain.SessionFilter{
domain.SessionUserAgentFilter{FingerprintID: "explicit-fp"},
},
},
expectedCond: func(repo *domainmock.SessionRepo) database.Condition {
return database.And(
appendToDefaultConditions(repo, repo.UserAgentIDCondition("explicit-fp")),
)
},
},
{
name: "SessionExpirationDateFilter with GREATER_THAN includes null expiration",
request: &domain.ListSessionsRequest{
Filters: []domain.SessionFilter{
domain.SessionExpirationDateFilter{Op: database.NumberOperationGreaterThan, Date: now},
},
},
expectedCond: func(repo *domainmock.SessionRepo) database.Condition {
return database.And(
appendToDefaultConditions(repo,
database.Or(repo.ExpirationCondition(database.NumberOperationGreaterThan, now), database.IsNull(repo.ExpirationColumn())),
),
)
},
},
{
name: "SessionExpirationDateFilter with GREATER_OR_EQUALS includes null expiration",
request: &domain.ListSessionsRequest{
Filters: []domain.SessionFilter{
domain.SessionExpirationDateFilter{Op: database.NumberOperationGreaterThanOrEqual, Date: now},
},
},
expectedCond: func(repo *domainmock.SessionRepo) database.Condition {
return database.And(
appendToDefaultConditions(repo,
database.Or(repo.ExpirationCondition(database.NumberOperationGreaterThanOrEqual, now), database.IsNull(repo.ExpirationColumn())),
),
)
},
},
{
name: "SessionExpirationDateFilter with LESS_THAN does not include null expiration",
request: &domain.ListSessionsRequest{
Filters: []domain.SessionFilter{
domain.SessionExpirationDateFilter{Op: database.NumberOperationLessThan, Date: now},
},
},
expectedCond: func(repo *domainmock.SessionRepo) database.Condition {
return database.And(
appendToDefaultConditions(repo, repo.ExpirationCondition(database.NumberOperationLessThan, now)),
)
},
},
{
name: "SessionExpirationDateFilter with LESS_OR_EQUALS does not include null expiration",
request: &domain.ListSessionsRequest{
Filters: []domain.SessionFilter{
domain.SessionExpirationDateFilter{Op: database.NumberOperationLessThanOrEqual, Date: now},
},
},
expectedCond: func(repo *domainmock.SessionRepo) database.Condition {
return database.And(
appendToDefaultConditions(repo, repo.ExpirationCondition(database.NumberOperationLessThanOrEqual, now)),
)
},
},
{
name: "multiple filters combined with AND",
request: &domain.ListSessionsRequest{
Filters: []domain.SessionFilter{
domain.SessionUserIDFilter{UserID: "target-user"},
domain.SessionCreatorFilter{ID: "creator-1"},
},
},
expectedCond: func(repo *domainmock.SessionRepo) database.Condition {
return database.And(
appendToDefaultConditions(repo,
database.And(repo.UserIDCondition("target-user"), repo.CreatorIDCondition("creator-1")),
),
)
},
},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ctx := authz.NewMockContextWithAgent("inst-1", "org-1", userID, agentID)
ctrl := gomock.NewController(t)
sessionRepo := domainmock.NewSessionRepo(ctrl)
q := domain.NewListSessionsQuery(tc.request)
cond, err := q.Conditions(ctx, sessionRepo)
assert.ErrorIs(t, err, tc.expectedError)
expected := tc.expectedCond(sessionRepo)
assert.Equal(t, expected.String(), cond.String())
})
}
}
func TestListSessionsQuery_Execute(t *testing.T) {
t.Parallel()
const (
instanceID = "inst-1"
orgID = "org-1"
userID = "user-1"
agentID = "agent-fp-1"
)
listErr := errors.New("list mock error")
tt := []struct {
name string
request *domain.ListSessionsRequest
setupMock func(sessionRepo *domainmock.SessionRepo)
expectedSessions []*domain.Session
expectedError error
}{
{
name: "when List fails should return internal error",
request: &domain.ListSessionsRequest{},
setupMock: func(sessionRepo *domainmock.SessionRepo) {
instanceCond := sessionRepo.InstanceIDCondition(instanceID)
permCond := database.Or(
sessionRepo.UserIDCondition(userID),
sessionRepo.UserAgentIDCondition(agentID),
sessionRepo.CreatorIDCondition(userID),
database.PermissionCheck(domain.SessionReadPermission, false),
)
sessionRepo.EXPECT().
LoadUserData().
Times(1).
Return(sessionRepo)
sessionRepo.EXPECT().
List(gomock.Any(), gomock.Any(),
dbmock.QueryOptions(database.WithCondition(database.And(instanceCond, permCond))),
dbmock.QueryOptions(func(*database.QueryOpts) {}),
dbmock.QueryOptions(database.WithLimit(0)),
dbmock.QueryOptions(database.WithOffset(0)),
).
Times(1).
Return(nil, listErr)
},
expectedError: zerrors.ThrowInternal(listErr, "DOM-Yx8q2r", "Errors.Session.List"),
},
{
name: "when List succeeds should return sessions",
request: &domain.ListSessionsRequest{
SortColumn: domain.SessionSortColumnCreationDate,
Ascending: true,
Limit: 10,
Offset: 5,
},
setupMock: func(sessionRepo *domainmock.SessionRepo) {
instanceCond := sessionRepo.InstanceIDCondition(instanceID)
permCond := database.Or(
sessionRepo.UserIDCondition(userID),
sessionRepo.UserAgentIDCondition(agentID),
sessionRepo.CreatorIDCondition(userID),
database.PermissionCheck(domain.SessionReadPermission, false),
)
sessionRepo.EXPECT().
LoadUserData().
Times(1).
Return(sessionRepo)
sessionRepo.EXPECT().
List(gomock.Any(), gomock.Any(),
dbmock.QueryOptions(database.WithCondition(database.And(instanceCond, permCond))),
dbmock.QueryOptions(database.WithOrderBy(database.OrderDirectionAsc, sessionRepo.CreatedAtColumn())),
dbmock.QueryOptions(database.WithLimit(10)),
dbmock.QueryOptions(database.WithOffset(5)),
).
Times(1).
Return([]*domain.Session{{ID: "session-1", UserID: userID}}, nil)
},
expectedSessions: []*domain.Session{{ID: "session-1", UserID: userID}},
},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ctx := authz.NewMockContextWithAgent(instanceID, orgID, userID, agentID)
ctrl := gomock.NewController(t)
sessionRepo := domainmock.NewSessionRepo(ctrl)
if tc.setupMock != nil {
tc.setupMock(sessionRepo)
}
opts := domain.DefaultOpts(nil)
domain.WithQueryExecutor(new(noopdb.Pool))(opts)
domain.WithSessionRepo(sessionRepo)(opts)
q := domain.NewListSessionsQuery(tc.request)
err := q.Execute(ctx, opts)
assert.ErrorIs(t, err, tc.expectedError)
assert.ElementsMatch(t, tc.expectedSessions, q.Result())
})
}
}
+1 -1
View File
@@ -225,7 +225,7 @@ type IdentityProviderLink struct {
UpdatedAt time.Time `json:"updatedAt,omitzero" db:"-"`
}
//go:generate enumer -type UserType -transform lower -trimprefix UserType
//go:generate enumer -type UserType -transform lower -trimprefix UserType -sql
type UserType uint8
+31
View File
@@ -3,6 +3,7 @@
package domain
import (
"database/sql/driver"
"fmt"
"strings"
)
@@ -80,3 +81,33 @@ func (i UserType) IsAUserType() bool {
}
return false
}
func (i UserType) Value() (driver.Value, error) {
return i.String(), nil
}
func (i *UserType) Scan(value interface{}) error {
if value == nil {
return nil
}
var str string
switch v := value.(type) {
case []byte:
str = string(v)
case string:
str = v
case fmt.Stringer:
str = v.String()
default:
return fmt.Errorf("invalid value of UserType: %[1]T(%[1]v)", value)
}
val, err := UserTypeString(str)
if err != nil {
return err
}
*i = val
return nil
}
@@ -33,11 +33,17 @@ BEGIN
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER set_expiration
BEFORE INSERT OR UPDATE OF lifetime ON zitadel.sessions
CREATE TRIGGER set_expiration_on_update
BEFORE UPDATE OF lifetime ON zitadel.sessions
FOR EACH ROW
EXECUTE FUNCTION zitadel.update_expiration();
CREATE TRIGGER set_expiration_on_insert
BEFORE INSERT ON zitadel.sessions
FOR EACH ROW
WHEN (NEW.lifetime <> '0'::interval)
EXECUTE FUNCTION zitadel.update_expiration();
CREATE TYPE zitadel.session_factor_type AS ENUM (
'user',
'password',
@@ -0,0 +1,16 @@
package migration
import (
_ "embed"
)
var (
//go:embed 018_administrator_role_permissions_table/up.sql
up018AdministratorRolePermissionsTable string
//go:embed 018_administrator_role_permissions_table/down.sql
down018AdministratorRolePermissionsTable string
)
func init() {
registerSQLMigration(18, up018AdministratorRolePermissionsTable, down018AdministratorRolePermissionsTable)
}
Loaded 100 of 457 files, more files were not shown because too many files have changed in this diff. Show more