mirror of
https://github.com/andwati/zitadel.git
synced 2026-11-02 12:22:15 +00:00
Closes #11923 # Which Problems Are Solved Client-side gRPC errors (e.g. NotFound, InvalidArgument, PermissionDenied) were being surfaced as HTTP 500 Internal Server Errors, causing false SRE alerts and poor UI feedback. # How the Problems Are Solved - New transport interceptor (`error-classification.ts`): Automatically enriches every ConnectError with httpStatus and isUserError metadata via a `ClassifiedConnectError` wrapper - Route handler protection (`route.ts`, `flow-initiation.ts`): Catches classified errors from `getAuthRequest` / `getSAMLRequest` and returns correct HTTP status codes instead of 500 - Type safety: Replaced all magic number error.code === 9 checks with typed Code.FailedPrecondition + instanceof ConnectError across `oidc.ts`, `saml.ts`, `password.ts`, `zitadel.ts` - Classification-aware logging (`session.ts`): Client/user errors log at warn level, server errors at error level - Observability (`otel.ts`): Spans now include error.is_user_error and http.status_code attributes for alert filtering # Additional Changes Gitignore `next-env.d.ts`: This file is auto-generated by Next.js on every next dev and next build invocation. The two modes write slightly different import paths (.next/dev/types/ vs .next/types/), which causes git diff --exit-code to fail in CI whenever a developer runs next dev locally before committing. Since Next.js regenerates it automatically, there's no need to track it --------- Co-authored-by: Ramon <[email protected]> Co-authored-by: Copilot <[email protected]>
1385 lines
37 KiB
TypeScript
1385 lines
37 KiB
TypeScript
import { createConnectTransport } from "@connectrpc/connect-node";
|
|
import { Client, create, Duration } from "@zitadel/client";
|
|
import { makeReqCtx } from "@zitadel/client/v2";
|
|
import { IdentityProviderService } from "@zitadel/proto/zitadel/idp/v2/idp_service_pb";
|
|
import { OrganizationSchema, TextQueryMethod } from "@zitadel/proto/zitadel/object/v2/object_pb";
|
|
import { CreateCallbackRequest, OIDCService } from "@zitadel/proto/zitadel/oidc/v2/oidc_service_pb";
|
|
import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb";
|
|
import { OrganizationService } from "@zitadel/proto/zitadel/org/v2/org_service_pb";
|
|
import { CreateResponseRequest, SAMLService } from "@zitadel/proto/zitadel/saml/v2/saml_service_pb";
|
|
import { RequestChallenges } from "@zitadel/proto/zitadel/session/v2/challenge_pb";
|
|
import { Checks, SessionService } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
|
|
import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
|
|
import { SettingsService } from "@zitadel/proto/zitadel/settings/v2/settings_service_pb";
|
|
import { SendEmailVerificationCodeSchema } from "@zitadel/proto/zitadel/user/v2/email_pb";
|
|
import type { FormData, RedirectURLsJson } from "@zitadel/proto/zitadel/user/v2/idp_pb";
|
|
import { NotificationType, SendPasswordResetLinkSchema } from "@zitadel/proto/zitadel/user/v2/password_pb";
|
|
import { SearchQuery, SearchQuerySchema } from "@zitadel/proto/zitadel/user/v2/query_pb";
|
|
import { SendInviteCodeSchema } from "@zitadel/proto/zitadel/user/v2/user_pb";
|
|
import {
|
|
AddHumanUserRequest,
|
|
AddHumanUserRequestSchema,
|
|
ResendEmailCodeRequest,
|
|
ResendEmailCodeRequestSchema,
|
|
SendEmailCodeRequestSchema,
|
|
SetPasswordRequest,
|
|
SetPasswordRequestSchema,
|
|
UpdateHumanUserRequest,
|
|
UserService,
|
|
VerifyPasskeyRegistrationRequest,
|
|
VerifyU2FRegistrationRequest,
|
|
} from "@zitadel/proto/zitadel/user/v2/user_service_pb";
|
|
import { getTranslations } from "next-intl/server";
|
|
|
|
import { getUserAgent } from "./fingerprint";
|
|
import { createLogger } from "./logger";
|
|
|
|
import { errorClassificationInterceptor } from "@/lib/grpc/interceptors/error-classification";
|
|
import { otelGrpcInterceptor } from "@/lib/grpc/interceptors/otel";
|
|
import { Code, ConnectError, Interceptor } from "@connectrpc/connect";
|
|
import { createServiceForHost } from "./service";
|
|
|
|
const logger = createLogger("zitadel");
|
|
|
|
const useCache = process.env.API_CACHE_ENABLED !== "false";
|
|
|
|
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);
|
|
}
|
|
|
|
const defaultCacheTTL = (cacheConfig.defaultMinutes ?? 15) * 60 * 1000; // 15 mins default
|
|
const longCacheTTL = (cacheConfig.longMinutes ?? 60) * 60 * 1000; // 1 hour default
|
|
|
|
/**
|
|
* Helper to determine the TTL for a specific API method.
|
|
* Allows overriding per-resource via the API_CACHE_CONFIG environment variable.
|
|
*/
|
|
function getTTLForKey(keyPrefix: string, fallbackTtl: number) {
|
|
const configTTL = cacheConfig[keyPrefix];
|
|
if (typeof configTTL === "number" && !isNaN(configTTL)) {
|
|
return configTTL * 60 * 1000; // Config expects minutes
|
|
}
|
|
return fallbackTtl;
|
|
}
|
|
|
|
const promiseCache = new Map<string, { promise: Promise<any>; expiresAt: number }>();
|
|
|
|
/**
|
|
* A stale-while-revalidate in-memory cache to keep data fresh and deduplicate concurrent requests.
|
|
* We cache the Promise, so concurrent requests share the exact same execution.
|
|
*/
|
|
function freshCache<T>(key: string, fetcher: () => Promise<T>, ttlMs: number): Promise<T> {
|
|
if (!useCache) {
|
|
return fetcher();
|
|
}
|
|
|
|
const now = Date.now();
|
|
const cached = promiseCache.get(key);
|
|
if (cached && now < cached.expiresAt) {
|
|
return cached.promise;
|
|
}
|
|
|
|
const promise = fetcher();
|
|
promiseCache.set(key, { promise, expiresAt: now + ttlMs });
|
|
|
|
promise.catch(() => promiseCache.delete(key));
|
|
|
|
return promise;
|
|
}
|
|
|
|
export async function getHostedLoginTranslation({
|
|
serviceConfig,
|
|
organization,
|
|
locale,
|
|
}: WithServiceConfig<{
|
|
organization?: string;
|
|
locale?: string;
|
|
}>) {
|
|
const fetcher = async () => {
|
|
const settingsService: Client<typeof SettingsService> = await createServiceForHost(SettingsService, serviceConfig);
|
|
|
|
return settingsService
|
|
.getHostedLoginTranslation(
|
|
{
|
|
level: organization
|
|
? {
|
|
case: "organizationId",
|
|
value: organization,
|
|
}
|
|
: {
|
|
case: "instance",
|
|
value: true,
|
|
},
|
|
locale: locale,
|
|
},
|
|
{},
|
|
)
|
|
.then((resp) => {
|
|
return resp.translations ? resp.translations : undefined;
|
|
});
|
|
};
|
|
|
|
return freshCache(
|
|
`getHostedLoginTranslation-${organization || "instance"}-${locale || "default"}`,
|
|
fetcher,
|
|
getTTLForKey("getHostedLoginTranslation", longCacheTTL),
|
|
);
|
|
}
|
|
|
|
export async function getBrandingSettings({
|
|
serviceConfig,
|
|
organization,
|
|
}: WithServiceConfig<{
|
|
organization?: string;
|
|
}>) {
|
|
const fetcher = async () => {
|
|
const settingsService: Client<typeof SettingsService> = await createServiceForHost(SettingsService, serviceConfig);
|
|
|
|
return settingsService
|
|
.getBrandingSettings({ ctx: makeReqCtx(organization) }, {})
|
|
.then((resp) => (resp.settings ? resp.settings : undefined));
|
|
};
|
|
|
|
return freshCache(
|
|
`getBrandingSettings-${organization || "instance"}`,
|
|
fetcher,
|
|
getTTLForKey("getBrandingSettings", longCacheTTL),
|
|
);
|
|
}
|
|
|
|
export async function getLoginSettings({
|
|
serviceConfig,
|
|
organization,
|
|
}: WithServiceConfig<{
|
|
organization?: string;
|
|
}>) {
|
|
const fetcher = async () => {
|
|
const settingsService: Client<typeof SettingsService> = await createServiceForHost(SettingsService, serviceConfig);
|
|
|
|
return settingsService
|
|
.getLoginSettings({ ctx: makeReqCtx(organization) }, {})
|
|
.then((resp) => (resp.settings ? resp.settings : undefined));
|
|
};
|
|
|
|
return freshCache(
|
|
`getLoginSettings-${organization || "instance"}`,
|
|
fetcher,
|
|
getTTLForKey("getLoginSettings", defaultCacheTTL),
|
|
);
|
|
}
|
|
|
|
export async function getSecuritySettings({ serviceConfig }: WithServiceConfig) {
|
|
const fetcher = async () => {
|
|
const settingsService: Client<typeof SettingsService> = await createServiceForHost(SettingsService, serviceConfig);
|
|
|
|
return settingsService.getSecuritySettings({}).then((resp) => (resp.settings ? resp.settings : undefined));
|
|
};
|
|
|
|
return freshCache(`getSecuritySettings-instance`, fetcher, getTTLForKey("getSecuritySettings", defaultCacheTTL));
|
|
}
|
|
|
|
export async function getLockoutSettings({ serviceConfig, orgId }: WithServiceConfig<{ orgId?: string }>) {
|
|
const fetcher = async () => {
|
|
const settingsService: Client<typeof SettingsService> = await createServiceForHost(SettingsService, serviceConfig);
|
|
|
|
return settingsService
|
|
.getLockoutSettings({ ctx: makeReqCtx(orgId) }, {})
|
|
.then((resp) => (resp.settings ? resp.settings : undefined));
|
|
};
|
|
|
|
return freshCache(
|
|
`getLockoutSettings-${orgId || "instance"}`,
|
|
fetcher,
|
|
getTTLForKey("getLockoutSettings", defaultCacheTTL),
|
|
);
|
|
}
|
|
|
|
export async function getPasswordExpirySettings({ serviceConfig, orgId }: WithServiceConfig<{ orgId?: string }>) {
|
|
const fetcher = async () => {
|
|
const settingsService: Client<typeof SettingsService> = await createServiceForHost(SettingsService, serviceConfig);
|
|
|
|
return settingsService
|
|
.getPasswordExpirySettings({ ctx: makeReqCtx(orgId) }, {})
|
|
.then((resp) => (resp.settings ? resp.settings : undefined));
|
|
};
|
|
|
|
return freshCache(
|
|
`getPasswordExpirySettings-${orgId || "instance"}`,
|
|
fetcher,
|
|
getTTLForKey("getPasswordExpirySettings", defaultCacheTTL),
|
|
);
|
|
}
|
|
|
|
export async function listIDPLinks({ serviceConfig, userId }: WithServiceConfig<{ userId: string }>) {
|
|
const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig);
|
|
|
|
return userService.listIDPLinks({ userId }, {});
|
|
}
|
|
|
|
export async function addOTPEmail({ serviceConfig, userId }: WithServiceConfig<{ userId: string }>) {
|
|
const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig);
|
|
|
|
return userService.addOTPEmail({ userId }, {});
|
|
}
|
|
|
|
export async function addOTPSMS({ serviceConfig, userId }: WithServiceConfig<{ userId: string }>) {
|
|
const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig);
|
|
|
|
return userService.addOTPSMS({ userId }, {});
|
|
}
|
|
|
|
export async function registerTOTP({ serviceConfig, userId }: WithServiceConfig<{ userId: string }>) {
|
|
const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig);
|
|
|
|
return userService.registerTOTP({ userId }, {});
|
|
}
|
|
|
|
export async function getAllowedLanguages({ serviceConfig }: WithServiceConfig) {
|
|
const fetcher = async () => {
|
|
const settingsService: Client<typeof SettingsService> = await createServiceForHost(SettingsService, serviceConfig);
|
|
|
|
return settingsService.getGeneralSettings({}, {}).then((resp) => {
|
|
return {
|
|
allowedLanguages: resp.allowedLanguages,
|
|
defaultLanguage: resp.defaultLanguage,
|
|
};
|
|
});
|
|
};
|
|
|
|
return freshCache(`getGeneralSettings-instance`, fetcher, getTTLForKey("getGeneralSettings", longCacheTTL));
|
|
}
|
|
|
|
export async function getLegalAndSupportSettings({
|
|
serviceConfig,
|
|
organization,
|
|
}: WithServiceConfig<{
|
|
organization?: string;
|
|
}>) {
|
|
const fetcher = async () => {
|
|
const settingsService: Client<typeof SettingsService> = await createServiceForHost(SettingsService, serviceConfig);
|
|
|
|
return settingsService
|
|
.getLegalAndSupportSettings({ ctx: makeReqCtx(organization) }, {})
|
|
.then((resp) => (resp.settings ? resp.settings : undefined));
|
|
};
|
|
|
|
return freshCache(
|
|
`getLegalAndSupportSettings-${organization || "instance"}`,
|
|
fetcher,
|
|
getTTLForKey("getLegalAndSupportSettings", longCacheTTL),
|
|
);
|
|
}
|
|
|
|
export async function getPasswordComplexitySettings({
|
|
serviceConfig,
|
|
organization,
|
|
}: WithServiceConfig<{
|
|
organization?: string;
|
|
}>) {
|
|
const fetcher = async () => {
|
|
const settingsService: Client<typeof SettingsService> = await createServiceForHost(SettingsService, serviceConfig);
|
|
|
|
return settingsService
|
|
.getPasswordComplexitySettings({ ctx: makeReqCtx(organization) })
|
|
.then((resp) => (resp.settings ? resp.settings : undefined));
|
|
};
|
|
|
|
return freshCache(
|
|
`getPasswordComplexitySettings-${organization || "instance"}`,
|
|
fetcher,
|
|
getTTLForKey("getPasswordComplexitySettings", defaultCacheTTL),
|
|
);
|
|
}
|
|
|
|
export async function createSessionFromChecksAndChallenges({
|
|
serviceConfig,
|
|
checks,
|
|
challenges,
|
|
lifetime,
|
|
}: WithServiceConfig<{
|
|
checks: Checks;
|
|
challenges?: RequestChallenges;
|
|
lifetime: Duration;
|
|
}>) {
|
|
const sessionService: Client<typeof SessionService> = await createServiceForHost(SessionService, serviceConfig);
|
|
|
|
const userAgent = await getUserAgent();
|
|
|
|
return sessionService.createSession({ ...{ checks, lifetime, userAgent }, ...(challenges ? { challenges } : {}) }, {});
|
|
}
|
|
|
|
export async function createSessionForUserIdAndIdpIntent({
|
|
serviceConfig,
|
|
userId,
|
|
idpIntent,
|
|
lifetime,
|
|
}: WithServiceConfig<{
|
|
userId: string;
|
|
idpIntent: {
|
|
idpIntentId?: string | undefined;
|
|
idpIntentToken?: string | undefined;
|
|
};
|
|
lifetime: Duration;
|
|
}>) {
|
|
const sessionService: Client<typeof SessionService> = await createServiceForHost(SessionService, serviceConfig);
|
|
|
|
const userAgent = await getUserAgent();
|
|
|
|
return sessionService.createSession({
|
|
checks: {
|
|
user: {
|
|
search: {
|
|
case: "userId",
|
|
value: userId,
|
|
},
|
|
},
|
|
idpIntent,
|
|
},
|
|
lifetime,
|
|
userAgent,
|
|
});
|
|
}
|
|
|
|
export async function setSession({
|
|
serviceConfig,
|
|
sessionId,
|
|
sessionToken,
|
|
challenges,
|
|
checks,
|
|
lifetime,
|
|
}: WithServiceConfig<{
|
|
sessionId: string;
|
|
sessionToken: string;
|
|
challenges: RequestChallenges | undefined;
|
|
checks?: Checks;
|
|
lifetime: Duration;
|
|
}>) {
|
|
const sessionService: Client<typeof SessionService> = await createServiceForHost(SessionService, serviceConfig);
|
|
|
|
return sessionService.setSession(
|
|
{
|
|
sessionId,
|
|
sessionToken,
|
|
challenges,
|
|
checks: checks ? checks : {},
|
|
metadata: {},
|
|
lifetime,
|
|
},
|
|
{},
|
|
);
|
|
}
|
|
|
|
export async function getSession({
|
|
serviceConfig,
|
|
sessionId,
|
|
sessionToken,
|
|
}: WithServiceConfig<{
|
|
sessionId: string;
|
|
sessionToken: string;
|
|
}>) {
|
|
const sessionService: Client<typeof SessionService> = await createServiceForHost(SessionService, serviceConfig);
|
|
|
|
return sessionService.getSession({ sessionId, sessionToken }, {});
|
|
}
|
|
|
|
export async function deleteSession({
|
|
serviceConfig,
|
|
sessionId,
|
|
sessionToken,
|
|
}: WithServiceConfig<{
|
|
sessionId: string;
|
|
sessionToken: string;
|
|
}>) {
|
|
const sessionService: Client<typeof SessionService> = await createServiceForHost(SessionService, serviceConfig);
|
|
|
|
return sessionService.deleteSession({ sessionId, sessionToken }, {});
|
|
}
|
|
|
|
type ListSessionsCommand = WithServiceConfig<{
|
|
ids: string[];
|
|
}>;
|
|
|
|
export async function listSessions({ serviceConfig, ids }: ListSessionsCommand) {
|
|
const sessionService: Client<typeof SessionService> = await createServiceForHost(SessionService, serviceConfig);
|
|
|
|
return sessionService.listSessions(
|
|
{
|
|
queries: [
|
|
{
|
|
query: {
|
|
case: "idsQuery",
|
|
value: { ids },
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{},
|
|
);
|
|
}
|
|
|
|
export type AddHumanUserData = WithServiceConfig<{
|
|
firstName: string;
|
|
lastName: string;
|
|
email: string;
|
|
password?: string;
|
|
organization: string;
|
|
}>;
|
|
|
|
export async function addHumanUser({ serviceConfig, email, firstName, lastName, password, organization }: AddHumanUserData) {
|
|
const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig);
|
|
|
|
let addHumanUserRequest: AddHumanUserRequest = create(AddHumanUserRequestSchema, {
|
|
email: {
|
|
email,
|
|
verification: {
|
|
case: "isVerified",
|
|
value: false,
|
|
},
|
|
},
|
|
username: email,
|
|
profile: { givenName: firstName, familyName: lastName },
|
|
passwordType: password ? { case: "password", value: { password } } : undefined,
|
|
});
|
|
|
|
if (organization) {
|
|
const organizationSchema = create(OrganizationSchema, {
|
|
org: { case: "orgId", value: organization },
|
|
});
|
|
|
|
addHumanUserRequest = {
|
|
...addHumanUserRequest,
|
|
organization: organizationSchema,
|
|
};
|
|
}
|
|
|
|
return userService.addHumanUser(addHumanUserRequest);
|
|
}
|
|
|
|
export async function addHuman({ serviceConfig, request }: WithServiceConfig<{ request: AddHumanUserRequest }>) {
|
|
const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig);
|
|
|
|
return userService.addHumanUser(request);
|
|
}
|
|
|
|
export async function updateHuman({
|
|
serviceConfig,
|
|
request,
|
|
}: WithServiceConfig<{
|
|
request: UpdateHumanUserRequest;
|
|
}>) {
|
|
const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig);
|
|
|
|
return userService.updateHumanUser(request);
|
|
}
|
|
|
|
export async function verifyTOTPRegistration({
|
|
serviceConfig,
|
|
code,
|
|
userId,
|
|
}: WithServiceConfig<{
|
|
code: string;
|
|
userId: string;
|
|
}>) {
|
|
const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig);
|
|
|
|
return userService.verifyTOTPRegistration({ code, userId }, {});
|
|
}
|
|
|
|
export async function getUserByID({ serviceConfig, userId }: WithServiceConfig<{ userId: string }>) {
|
|
const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig);
|
|
|
|
return userService.getUserByID({ userId }, {});
|
|
}
|
|
|
|
export async function humanMFAInitSkipped({ serviceConfig, userId }: WithServiceConfig<{ userId: string }>) {
|
|
const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig);
|
|
|
|
return userService.humanMFAInitSkipped({ userId }, {});
|
|
}
|
|
|
|
export async function verifyInviteCode({
|
|
serviceConfig,
|
|
userId,
|
|
verificationCode,
|
|
}: WithServiceConfig<{
|
|
userId: string;
|
|
verificationCode: string;
|
|
}>) {
|
|
const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig);
|
|
|
|
return userService.verifyInviteCode({ userId, verificationCode }, {});
|
|
}
|
|
|
|
export async function sendEmailCode({
|
|
serviceConfig,
|
|
userId,
|
|
urlTemplate,
|
|
}: WithServiceConfig<{
|
|
userId: string;
|
|
urlTemplate: string;
|
|
}>) {
|
|
let medium = create(SendEmailCodeRequestSchema, { userId });
|
|
|
|
medium = create(SendEmailCodeRequestSchema, {
|
|
...medium,
|
|
verification: {
|
|
case: "sendCode",
|
|
value: create(SendEmailVerificationCodeSchema, {
|
|
urlTemplate,
|
|
}),
|
|
},
|
|
});
|
|
|
|
const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig);
|
|
|
|
return userService.sendEmailCode(medium, {});
|
|
}
|
|
|
|
export async function createInviteCode({
|
|
serviceConfig,
|
|
urlTemplate,
|
|
userId,
|
|
}: WithServiceConfig<{
|
|
urlTemplate: string;
|
|
userId: string;
|
|
}>) {
|
|
let medium = create(SendInviteCodeSchema, {
|
|
applicationName: process.env.NEXT_PUBLIC_APPLICATION_NAME || "Zitadel Login",
|
|
});
|
|
|
|
medium = {
|
|
...medium,
|
|
urlTemplate,
|
|
};
|
|
|
|
const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig);
|
|
|
|
return userService.createInviteCode(
|
|
{
|
|
userId,
|
|
verification: {
|
|
case: "sendCode",
|
|
value: medium,
|
|
},
|
|
},
|
|
{},
|
|
);
|
|
}
|
|
|
|
export type ListUsersCommand = WithServiceConfig<{
|
|
loginName?: string;
|
|
userName?: string;
|
|
email?: string;
|
|
phone?: string;
|
|
organizationId?: string;
|
|
}>;
|
|
|
|
export async function listUsers({ serviceConfig, loginName, userName, phone, email, organizationId }: ListUsersCommand) {
|
|
const queries: SearchQuery[] = [];
|
|
|
|
// either use loginName or userName, email, phone
|
|
if (loginName) {
|
|
queries.push(
|
|
create(SearchQuerySchema, {
|
|
query: {
|
|
case: "loginNameQuery",
|
|
value: {
|
|
loginName,
|
|
method: TextQueryMethod.EQUALS,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
} else if (userName || email || phone) {
|
|
const orQueries: SearchQuery[] = [];
|
|
|
|
if (userName) {
|
|
const userNameQuery = create(SearchQuerySchema, {
|
|
query: {
|
|
case: "userNameQuery",
|
|
value: {
|
|
userName,
|
|
method: TextQueryMethod.EQUALS,
|
|
},
|
|
},
|
|
});
|
|
orQueries.push(userNameQuery);
|
|
}
|
|
|
|
if (email) {
|
|
const emailQuery = create(SearchQuerySchema, {
|
|
query: {
|
|
case: "emailQuery",
|
|
value: {
|
|
emailAddress: email,
|
|
method: TextQueryMethod.EQUALS,
|
|
},
|
|
},
|
|
});
|
|
orQueries.push(emailQuery);
|
|
}
|
|
|
|
if (phone) {
|
|
const phoneQuery = create(SearchQuerySchema, {
|
|
query: {
|
|
case: "phoneQuery",
|
|
value: {
|
|
number: phone,
|
|
method: TextQueryMethod.EQUALS,
|
|
},
|
|
},
|
|
});
|
|
orQueries.push(phoneQuery);
|
|
}
|
|
|
|
queries.push(
|
|
create(SearchQuerySchema, {
|
|
query: {
|
|
case: "orQuery",
|
|
value: {
|
|
queries: orQueries,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
|
|
if (organizationId) {
|
|
queries.push(
|
|
create(SearchQuerySchema, {
|
|
query: {
|
|
case: "organizationIdQuery",
|
|
value: {
|
|
organizationId,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
|
|
const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig);
|
|
|
|
return userService.listUsers({ queries });
|
|
}
|
|
|
|
export type SearchUsersCommand = WithServiceConfig<{
|
|
searchValue: string;
|
|
loginSettings: LoginSettings;
|
|
organizationId?: string;
|
|
suffix?: string;
|
|
}>;
|
|
|
|
const PhoneQuery = (searchValue: string) =>
|
|
create(SearchQuerySchema, {
|
|
query: {
|
|
case: "phoneQuery",
|
|
value: {
|
|
number: searchValue,
|
|
method: TextQueryMethod.EQUALS,
|
|
},
|
|
},
|
|
});
|
|
|
|
const LoginNameQuery = (searchValue: string) =>
|
|
create(SearchQuerySchema, {
|
|
query: {
|
|
case: "loginNameQuery",
|
|
value: {
|
|
loginName: searchValue,
|
|
method: TextQueryMethod.EQUALS_IGNORE_CASE,
|
|
},
|
|
},
|
|
});
|
|
|
|
const EmailQuery = (searchValue: string) =>
|
|
create(SearchQuerySchema, {
|
|
query: {
|
|
case: "emailQuery",
|
|
value: {
|
|
emailAddress: searchValue,
|
|
method: TextQueryMethod.EQUALS_IGNORE_CASE,
|
|
},
|
|
},
|
|
});
|
|
|
|
/**
|
|
* this is a dedicated search function to search for users from the loginname page
|
|
* it searches users based on the loginName or userName and org suffix combination, and falls back to email and phone if no users are found
|
|
* */
|
|
export async function searchUsers({
|
|
serviceConfig,
|
|
searchValue,
|
|
loginSettings,
|
|
organizationId,
|
|
suffix,
|
|
}: SearchUsersCommand) {
|
|
const queries: SearchQuery[] = [];
|
|
|
|
const t = await getTranslations("zitadel");
|
|
|
|
// if a suffix is provided, we search for the userName concatenated with the suffix
|
|
if (suffix) {
|
|
const searchValueWithSuffix = `${searchValue}@${suffix}`;
|
|
const loginNameQuery = LoginNameQuery(searchValueWithSuffix);
|
|
queries.push(loginNameQuery);
|
|
} else {
|
|
const loginNameQuery = LoginNameQuery(searchValue);
|
|
queries.push(loginNameQuery);
|
|
}
|
|
|
|
if (organizationId) {
|
|
queries.push(
|
|
create(SearchQuerySchema, {
|
|
query: {
|
|
case: "organizationIdQuery",
|
|
value: {
|
|
organizationId,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
|
|
const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig);
|
|
|
|
const loginNameResult = await userService.listUsers({ queries });
|
|
|
|
if (!loginNameResult || !loginNameResult.details) {
|
|
return { error: t("errors.errorOccured") };
|
|
}
|
|
|
|
if (loginNameResult.result.length > 1) {
|
|
return { error: t("errors.multipleUsersFound") };
|
|
}
|
|
|
|
if (loginNameResult.result.length == 1) {
|
|
return loginNameResult;
|
|
}
|
|
|
|
const emailAndPhoneQueries: SearchQuery[] = [];
|
|
if (loginSettings.disableLoginWithEmail && loginSettings.disableLoginWithPhone) {
|
|
// Both email and phone login are disabled, return empty result
|
|
return { result: [] };
|
|
} else if (loginSettings.disableLoginWithEmail && searchValue.length <= 20) {
|
|
const phoneQuery = PhoneQuery(searchValue);
|
|
emailAndPhoneQueries.push(phoneQuery);
|
|
} else if (loginSettings.disableLoginWithPhone) {
|
|
const emailQuery = EmailQuery(searchValue);
|
|
emailAndPhoneQueries.push(emailQuery);
|
|
} else {
|
|
const orQuery: SearchQuery[] = [];
|
|
|
|
const emailQuery = EmailQuery(searchValue);
|
|
orQuery.push(emailQuery);
|
|
|
|
let phoneQuery;
|
|
if (searchValue.length <= 20) {
|
|
phoneQuery = PhoneQuery(searchValue);
|
|
orQuery.push(phoneQuery);
|
|
}
|
|
|
|
emailAndPhoneQueries.push(
|
|
create(SearchQuerySchema, {
|
|
query: {
|
|
case: "orQuery",
|
|
value: {
|
|
queries: orQuery,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
|
|
if (organizationId) {
|
|
emailAndPhoneQueries.push(
|
|
create(SearchQuerySchema, {
|
|
query: {
|
|
case: "organizationIdQuery",
|
|
value: {
|
|
organizationId,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
|
|
const emailOrPhoneResult = await userService.listUsers({
|
|
queries: emailAndPhoneQueries,
|
|
});
|
|
|
|
if (!emailOrPhoneResult || !emailOrPhoneResult.details) {
|
|
return { error: t("errors.errorOccured") };
|
|
}
|
|
|
|
if (emailOrPhoneResult.result.length > 1) {
|
|
return { error: t("errors.multipleUsersFound") };
|
|
}
|
|
|
|
if (emailOrPhoneResult.result.length == 1) {
|
|
return emailOrPhoneResult;
|
|
}
|
|
|
|
// No users found - return empty result, not an error
|
|
return { result: [] };
|
|
}
|
|
|
|
export async function getDefaultOrg({ serviceConfig }: WithServiceConfig): Promise<Organization | null> {
|
|
const fetcher = async () => {
|
|
const orgService: Client<typeof OrganizationService> = await createServiceForHost(OrganizationService, serviceConfig);
|
|
|
|
return orgService
|
|
.listOrganizations(
|
|
{
|
|
queries: [
|
|
{
|
|
query: {
|
|
case: "defaultQuery",
|
|
value: {},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{},
|
|
)
|
|
.then((resp) => (resp?.result && resp.result[0] ? resp.result[0] : null));
|
|
};
|
|
|
|
return useCache
|
|
? freshCache(`getDefaultOrg-${"instance"}`, fetcher, getTTLForKey("getDefaultOrg", defaultCacheTTL))
|
|
: fetcher();
|
|
}
|
|
|
|
export async function getOrgsByDomain({ serviceConfig, domain }: WithServiceConfig<{ domain: string }>) {
|
|
const orgService: Client<typeof OrganizationService> = await createServiceForHost(OrganizationService, serviceConfig);
|
|
|
|
return orgService.listOrganizations(
|
|
{
|
|
queries: [
|
|
{
|
|
query: {
|
|
case: "domainQuery",
|
|
value: { domain, method: TextQueryMethod.EQUALS },
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{},
|
|
);
|
|
}
|
|
|
|
export async function startIdentityProviderFlow({
|
|
serviceConfig,
|
|
idpId,
|
|
urls,
|
|
}: WithServiceConfig<{
|
|
idpId: string;
|
|
urls: RedirectURLsJson;
|
|
}>): Promise<{ url: string; fields?: Record<string, string> } | null> {
|
|
// Use empty publicHost to avoid issues with redirect URIs pointing to the login UI instead of the zitadel API
|
|
const userService: Client<typeof UserService> = await createServiceForHost(UserService, {
|
|
...serviceConfig,
|
|
publicHost: "",
|
|
});
|
|
|
|
return userService
|
|
.startIdentityProviderIntent({
|
|
idpId,
|
|
content: {
|
|
case: "urls",
|
|
value: urls,
|
|
},
|
|
})
|
|
.then(async (resp) => {
|
|
if (resp.nextStep.case === "authUrl" && resp.nextStep.value) {
|
|
return { url: resp.nextStep.value };
|
|
} else if (resp.nextStep.case === "formData" && resp.nextStep.value) {
|
|
const formData: FormData = resp.nextStep.value;
|
|
|
|
return { url: formData.url, fields: formData.fields };
|
|
} else {
|
|
return null;
|
|
}
|
|
});
|
|
}
|
|
|
|
export async function startLDAPIdentityProviderFlow({
|
|
serviceConfig,
|
|
idpId,
|
|
username,
|
|
password,
|
|
}: WithServiceConfig<{
|
|
idpId: string;
|
|
username: string;
|
|
password: string;
|
|
}>) {
|
|
const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig);
|
|
|
|
return userService.startIdentityProviderIntent({
|
|
idpId,
|
|
content: {
|
|
case: "ldap",
|
|
value: {
|
|
username,
|
|
password,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
export async function getAuthRequest({
|
|
serviceConfig,
|
|
authRequestId,
|
|
}: WithServiceConfig<{
|
|
authRequestId: string;
|
|
}>) {
|
|
const oidcService = await createServiceForHost(OIDCService, serviceConfig);
|
|
|
|
return oidcService.getAuthRequest({
|
|
authRequestId,
|
|
});
|
|
}
|
|
|
|
export async function getDeviceAuthorizationRequest({
|
|
serviceConfig,
|
|
userCode,
|
|
}: WithServiceConfig<{
|
|
userCode: string;
|
|
}>) {
|
|
const oidcService = await createServiceForHost(OIDCService, serviceConfig);
|
|
|
|
return oidcService.getDeviceAuthorizationRequest({
|
|
userCode,
|
|
});
|
|
}
|
|
|
|
export async function authorizeOrDenyDeviceAuthorization({
|
|
serviceConfig,
|
|
deviceAuthorizationId,
|
|
session,
|
|
}: WithServiceConfig<{
|
|
deviceAuthorizationId: string;
|
|
session?: { sessionId: string; sessionToken: string };
|
|
}>) {
|
|
const oidcService = await createServiceForHost(OIDCService, serviceConfig);
|
|
|
|
return oidcService.authorizeOrDenyDeviceAuthorization({
|
|
deviceAuthorizationId,
|
|
decision: session
|
|
? {
|
|
case: "session",
|
|
value: session,
|
|
}
|
|
: {
|
|
case: "deny",
|
|
value: {},
|
|
},
|
|
});
|
|
}
|
|
|
|
export async function createCallback({ serviceConfig, req }: WithServiceConfig<{ req: CreateCallbackRequest }>) {
|
|
const oidcService = await createServiceForHost(OIDCService, serviceConfig);
|
|
|
|
return oidcService.createCallback(req);
|
|
}
|
|
|
|
export async function getSAMLRequest({
|
|
serviceConfig,
|
|
samlRequestId,
|
|
}: WithServiceConfig<{
|
|
samlRequestId: string;
|
|
}>) {
|
|
const samlService = await createServiceForHost(SAMLService, serviceConfig);
|
|
|
|
return samlService.getSAMLRequest({
|
|
samlRequestId,
|
|
});
|
|
}
|
|
|
|
export async function createResponse({ serviceConfig, req }: WithServiceConfig<{ req: CreateResponseRequest }>) {
|
|
const samlService = await createServiceForHost(SAMLService, serviceConfig);
|
|
|
|
return samlService.createResponse(req);
|
|
}
|
|
|
|
export async function verifyEmail({
|
|
serviceConfig,
|
|
userId,
|
|
verificationCode,
|
|
}: WithServiceConfig<{
|
|
userId: string;
|
|
verificationCode: string;
|
|
}>) {
|
|
const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig);
|
|
|
|
return userService.verifyEmail(
|
|
{
|
|
userId,
|
|
verificationCode,
|
|
},
|
|
{},
|
|
);
|
|
}
|
|
|
|
export async function resendEmailCode({
|
|
serviceConfig,
|
|
userId,
|
|
urlTemplate,
|
|
}: WithServiceConfig<{
|
|
userId: string;
|
|
urlTemplate: string;
|
|
}>) {
|
|
let request: ResendEmailCodeRequest = create(ResendEmailCodeRequestSchema, {
|
|
userId,
|
|
});
|
|
|
|
const medium = create(SendEmailVerificationCodeSchema, {
|
|
urlTemplate,
|
|
});
|
|
|
|
request = { ...request, verification: { case: "sendCode", value: medium } };
|
|
|
|
const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig);
|
|
|
|
return userService.resendEmailCode(request, {});
|
|
}
|
|
|
|
export async function retrieveIDPIntent({
|
|
serviceConfig,
|
|
id,
|
|
token,
|
|
}: WithServiceConfig<{
|
|
id: string;
|
|
token: string;
|
|
}>) {
|
|
const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig);
|
|
|
|
return userService.retrieveIdentityProviderIntent({ idpIntentId: id, idpIntentToken: token }, {});
|
|
}
|
|
|
|
export async function getIDPByID({ serviceConfig, id }: WithServiceConfig<{ id: string }>) {
|
|
const idpService: Client<typeof IdentityProviderService> = await createServiceForHost(
|
|
IdentityProviderService,
|
|
serviceConfig,
|
|
);
|
|
|
|
return idpService.getIDPByID({ id }, {}).then((resp) => resp.idp);
|
|
}
|
|
|
|
export async function addIDPLink({
|
|
serviceConfig,
|
|
idp,
|
|
userId,
|
|
}: WithServiceConfig<{
|
|
idp: { id: string; userId: string; userName: string };
|
|
userId: string;
|
|
}>) {
|
|
const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig);
|
|
|
|
return userService.addIDPLink(
|
|
{
|
|
idpLink: {
|
|
userId: idp.userId,
|
|
idpId: idp.id,
|
|
userName: idp.userName,
|
|
},
|
|
userId,
|
|
},
|
|
{},
|
|
);
|
|
}
|
|
|
|
export async function passwordReset({
|
|
serviceConfig,
|
|
userId,
|
|
urlTemplate,
|
|
}: WithServiceConfig<{
|
|
userId: string;
|
|
urlTemplate?: string;
|
|
}>) {
|
|
let medium = create(SendPasswordResetLinkSchema, {
|
|
notificationType: NotificationType.Email,
|
|
});
|
|
|
|
medium = {
|
|
...medium,
|
|
urlTemplate,
|
|
};
|
|
|
|
const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig);
|
|
|
|
return userService.passwordReset(
|
|
{
|
|
userId,
|
|
medium: {
|
|
case: "sendLink",
|
|
value: medium,
|
|
},
|
|
},
|
|
{},
|
|
);
|
|
}
|
|
|
|
export async function setUserPassword({
|
|
serviceConfig,
|
|
userId,
|
|
password,
|
|
code,
|
|
}: WithServiceConfig<{
|
|
userId: string;
|
|
password: string;
|
|
code?: string;
|
|
}>) {
|
|
let payload = create(SetPasswordRequestSchema, {
|
|
userId,
|
|
newPassword: {
|
|
password,
|
|
},
|
|
});
|
|
|
|
if (code) {
|
|
payload = {
|
|
...payload,
|
|
verification: {
|
|
case: "verificationCode",
|
|
value: code,
|
|
},
|
|
};
|
|
}
|
|
|
|
const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig);
|
|
|
|
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) {
|
|
return { error: error.message };
|
|
} else {
|
|
throw error;
|
|
}
|
|
});
|
|
}
|
|
|
|
export async function setPassword({
|
|
serviceConfig,
|
|
payload,
|
|
}: WithServiceConfig<{
|
|
payload: SetPasswordRequest;
|
|
}>) {
|
|
const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig);
|
|
|
|
return userService.setPassword(payload, {});
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param host
|
|
* @param userId the id of the user where the email should be set
|
|
* @returns the newly set email
|
|
*/
|
|
export async function createPasskeyRegistrationLink({
|
|
serviceConfig,
|
|
userId,
|
|
}: WithServiceConfig<{
|
|
userId: string;
|
|
}>) {
|
|
const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig);
|
|
|
|
return userService.createPasskeyRegistrationLink({
|
|
userId,
|
|
medium: {
|
|
case: "returnCode",
|
|
value: {},
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param host
|
|
* @param userId the id of the user where the email should be set
|
|
* @param domain the domain on which the factor is registered
|
|
* @returns the newly set email
|
|
*/
|
|
export async function registerU2F({
|
|
serviceConfig,
|
|
userId,
|
|
domain,
|
|
}: WithServiceConfig<{
|
|
userId: string;
|
|
domain: string;
|
|
}>) {
|
|
const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig);
|
|
|
|
return userService.registerU2F({
|
|
userId,
|
|
domain,
|
|
});
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param host
|
|
* @param request the request object for verifying U2F registration
|
|
* @returns the result of the verification
|
|
*/
|
|
export async function verifyU2FRegistration({
|
|
serviceConfig,
|
|
request,
|
|
}: WithServiceConfig<{
|
|
request: VerifyU2FRegistrationRequest;
|
|
}>) {
|
|
const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig);
|
|
|
|
return userService.verifyU2FRegistration(request, {});
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param host
|
|
* @param orgId the organization ID
|
|
* @param linking_allowed whether linking is allowed
|
|
* @returns the active identity providers
|
|
*/
|
|
export async function getActiveIdentityProviders({
|
|
serviceConfig,
|
|
orgId,
|
|
linking_allowed,
|
|
}: WithServiceConfig<{
|
|
orgId?: string;
|
|
linking_allowed?: boolean;
|
|
}>) {
|
|
const props: any = { ctx: makeReqCtx(orgId) };
|
|
if (linking_allowed) {
|
|
props.linkingAllowed = linking_allowed;
|
|
}
|
|
const settingsService: Client<typeof SettingsService> = await createServiceForHost(SettingsService, serviceConfig);
|
|
|
|
return settingsService.getActiveIdentityProviders(props, {});
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param host
|
|
* @param request the request object for verifying passkey registration
|
|
* @returns the result of the verification
|
|
*/
|
|
export async function verifyPasskeyRegistration({
|
|
serviceConfig,
|
|
request,
|
|
}: WithServiceConfig<{
|
|
request: VerifyPasskeyRegistrationRequest;
|
|
}>) {
|
|
const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig);
|
|
|
|
return userService.verifyPasskeyRegistration(request, {});
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param host
|
|
* @param userId the id of the user where the email should be set
|
|
* @param code the code for registering the passkey
|
|
* @param domain the domain on which the factor is registered
|
|
* @returns the newly set email
|
|
*/
|
|
export async function registerPasskey({
|
|
serviceConfig,
|
|
userId,
|
|
code,
|
|
domain,
|
|
}: WithServiceConfig<{
|
|
userId: string;
|
|
code: { id: string; code: string };
|
|
domain: string;
|
|
}>) {
|
|
const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig);
|
|
|
|
return userService.registerPasskey({
|
|
userId,
|
|
code,
|
|
domain,
|
|
});
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param host
|
|
* @param userId the id of the user where the email should be set
|
|
* @returns the list of authentication method types
|
|
*/
|
|
export async function listAuthenticationMethodTypes({
|
|
serviceConfig,
|
|
userId,
|
|
}: WithServiceConfig<{
|
|
userId: string;
|
|
}>) {
|
|
const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig);
|
|
|
|
return userService.listAuthenticationMethodTypes({
|
|
userId,
|
|
});
|
|
}
|
|
|
|
export interface ServiceConfig {
|
|
baseUrl: string;
|
|
instanceHost?: string; // only for multi-tenant
|
|
publicHost?: string; // only for multi-tenant
|
|
}
|
|
|
|
/**
|
|
* Base type that all function parameters must extend to ensure serviceConfig is always required
|
|
*/
|
|
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
export type WithServiceConfig<T = {}> = T & {
|
|
serviceConfig: ServiceConfig;
|
|
};
|
|
|
|
export function createServerTransport(token: string, serviceConfig: ServiceConfig) {
|
|
const authorizationInterceptor: Interceptor = (next) => (req) => {
|
|
if (!req.header.get("Authorization")) {
|
|
req.header.set("Authorization", `Bearer ${token}`);
|
|
}
|
|
return next(req);
|
|
};
|
|
|
|
const headerInterceptor: Interceptor = (next) => (req) => {
|
|
// Apply headers from serviceConfig
|
|
if (serviceConfig.instanceHost) {
|
|
req.header.set("x-zitadel-instance-host", serviceConfig.instanceHost);
|
|
}
|
|
if (serviceConfig.publicHost) {
|
|
req.header.set("x-zitadel-public-host", serviceConfig.publicHost);
|
|
}
|
|
|
|
// 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 });
|
|
}
|
|
});
|
|
}
|
|
|
|
return next(req);
|
|
};
|
|
|
|
return createConnectTransport({
|
|
httpVersion: "1.1",
|
|
baseUrl: serviceConfig.baseUrl,
|
|
interceptors: [otelGrpcInterceptor, errorClassificationInterceptor, authorizationInterceptor, headerInterceptor],
|
|
});
|
|
}
|