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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions app/modules/auth/providers/fake/fake-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,8 +291,13 @@ export class FakeAuthProvider implements AuthProvider {

// ─── users ────────────────────────────────────────────────────────────────────

async findUser(identifier: string, _orgId?: string): Promise<User | null> {
const u = this.users.find((u) => u.loginName === identifier) ?? null;
async findUser(identifier: string, orgId?: string): Promise<User | null> {
// Honour the org filter like the Zitadel adapter does, but only when BOTH sides carry an org:
// seeds without orgId keep matching regardless of the filter (pre-existing specs rely on it).
const u =
this.users.find(
(u) => u.loginName === identifier && (!orgId || !u.orgId || u.orgId === orgId)
) ?? null;
if (!u) return null;
const skippedAt = this.mfaSkippedAt.get(u.id) ?? null;
return skippedAt !== null ? { ...u, mfaInitSkippedAt: skippedAt } : u;
Expand Down
80 changes: 80 additions & 0 deletions app/resources/sso/idp-auto-create-allowlist.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// app/resources/sso/idp-auto-create-allowlist.ts
/**
* TEMPORARY — staging dual-org interim. Delete this module, its two env flags
* (`IDP_AUTO_CREATE_EMAIL_DOMAINS`, `IDP_AUTO_CREATE_ALIAS_TAG`), the single call site in
* `sso-callback.ts`, and ADR 007 once staging runs one Zitadel org for humans like production
* (design: docs/superpowers/specs/2026-09-21-staging-single-org-migration-design.md).
*
* WHY. Staging pins the staff portal to a Zitadel org whose login policy disallows
* registration, so a new staff member's first Google sign-in dead-ends on `creation-disabled`
* and an admin ends up creating a password user by hand. This lets an IdP-VERIFIED email from an
* allow-listed domain auto-create a user in that org anyway. Password self-signup stays off and
* the sign-up link stays hidden, because both key on the org's `allowRegister`, which is untouched.
*
* THE ALIAS. Zitadel usernames are unique instance-wide and auth-ui uses the email as the
* username. When the same email already owns a user in ANOTHER org, the user is created as
* `local+<tag>@domain` instead — the convention admins applied by hand until now. Google
* Workspace delivers plus-addressed mail to the same mailbox, so the IdP's verification of the
* base address is carried over to the alias.
*
* ADR BENDS, both deliberate and confined to this path:
* - ADR 005: the ownership lookup here is instance-wide even though the ceremony carries an
* explicit org, because the question is precisely "does another org own this email".
* - ADR 004: the flags gate WHETHER the door exists; the guards behind it (verified email,
* passwordless account for auto-link) are unchanged and still enforced by the decision.
*
* OFF STATE. With `IDP_AUTO_CREATE_EMAIL_DOMAINS` or `IDP_AUTO_CREATE_ORGS` unset (the default;
* production sets neither) none of this runs: the call site requires the target org to be listed
* in `IDP_AUTO_CREATE_ORGS` before consulting `isAllowlistedIdpEmail`.
*
* KNOWN FALLBACK. The Zitadel adapter's `findUser` returns null when an identifier matches MORE
* than one user (ADR 005's fail-closed rule). Here that reads as "no owner", so the plain email is
* attempted and Zitadel rejects it with ALREADY_EXISTS → `registration-conflict`. Fails closed;
* the alias is simply not offered in that (rare) case.
*/
import type { AuthProvider } from '@/modules/auth/auth-provider';

/** True when the IdP asserted a VERIFIED email whose domain is on the allow-list. */
export function isAllowlistedIdpEmail(
draft: { email?: string; emailVerified?: boolean } | null | undefined,
domains: readonly string[]
): boolean {
if (domains.length === 0 || !draft?.email || !draft.emailVerified) return false;
const at = draft.email.lastIndexOf('@');
if (at <= 0) return false;
return domains.includes(draft.email.slice(at + 1).toLowerCase());
}

/** `local@domain` → `local+<tag>@domain`. */
export function aliasEmail(email: string, tag: string): string {
const at = email.lastIndexOf('@');
return `${email.slice(0, at)}+${tag}${email.slice(at)}`;
}

export type AllowlistedRegistration =
/** A user in the TARGET org already owns the email (or its alias): take the existing-account path. */
| { kind: 'existing'; userId: string }
/** Register a new user in the target org under `email` (`aliased` when it is the alias form). */
| { kind: 'create'; email: string; aliased: boolean };

/**
* Decide how an allow-listed identity registers into `targetOrg`. An owner in another org means
* the plain email is taken instance-wide, so the alias is used unless the alias itself already
* exists in the target org. An owner whose org is unknown is treated as the target org's, which
* fails closed into the existing-account rules rather than minting an alias.
*/
export async function resolveAllowlistedRegistration(
provider: AuthProvider,
input: { email: string; targetOrg: string | undefined; aliasTag: string }
): Promise<AllowlistedRegistration> {
const { email, targetOrg, aliasTag } = input;
const owner = await provider.findUser(email, undefined);
if (!owner) return { kind: 'create', email, aliased: false };
if (owner.orgId === undefined || owner.orgId === targetOrg) {
return { kind: 'existing', userId: owner.id };
}
const alias = aliasEmail(email, aliasTag);
const aliasOwner = await provider.findUser(alias, targetOrg);
if (aliasOwner) return { kind: 'existing', userId: aliasOwner.id };
return { kind: 'create', email: alias, aliased: true };
}
55 changes: 48 additions & 7 deletions app/resources/sso/sso-callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ import { resolveOrg } from '@/resources/shared/resolve-org';
import { registerAndLinkIdp } from '@/resources/signup';
import { MAXMIND_TRACKING_TOKEN_METADATA_KEY } from '@/resources/signup/signup.service';
import { deriveIdpProfileName } from '@/resources/sso/derive-idp-name';
import {
isAllowlistedIdpEmail,
resolveAllowlistedRegistration,
} from '@/resources/sso/idp-auto-create-allowlist';
import { decideIdpCallback } from '@/resources/sso/idp-callback';
import { POLICY_ORG_PURPOSE } from '@/resources/sso/idp-return-urls';
import { signInWithIdpIntent, requestScopedProviderReads } from '@/resources/sso/idp-session';
Expand Down Expand Up @@ -151,6 +155,10 @@ export async function processIdpCallback(
let intent: IdpIntentResult;
let entries: Awaited<ReturnType<typeof readSessions>>;
let decision: ReturnType<typeof decideIdpCallback>;
// TEMPORARY (ADR 007) allow-list state, read again by the auto-create branch below.
let allowlisted: boolean | undefined;
let registerEmail: string | undefined;
let aliased = false;
// Resolve the effective org for this callback ceremony — policy-org-first (the org the START
// side decided the intent under, see idp-return-urls.ts), then the URL `?organization=`, then
// the ZITADEL_DEFAULT_ORG_ID env pin, then the provider's instance Default Organization.
Expand Down Expand Up @@ -196,18 +204,42 @@ export async function processIdpCallback(

const [sessionUserId, settings] = await Promise.all([sessionUserIdP, settingsP]);

// TEMPORARY (staging dual-org interim, ADR 007 — see idp-auto-create-allowlist.ts): an
// IdP-verified email from an allow-listed domain may register into an org whose policy
// disallows registration. Off (production) whenever IDP_AUTO_CREATE_EMAIL_DOMAINS is unset.
allowlisted =
link !== 'true' &&
!intent.userId &&
!settings.allowRegister &&
// Pinned to the listed org(s): callbackOrg can fall back to the raw ?organization= param.
callbackOrg !== undefined &&
env.IDP_AUTO_CREATE_ORGS.includes(callbackOrg) &&
isAllowlistedIdpEmail(intent.draft, env.IDP_AUTO_CREATE_EMAIL_DOMAINS);
const creationAllowed = settings.allowRegister || allowlisted;

// Resolve a same-email account ONLY on the register path (not linked, not a link ceremony,
// creation allowed, draft present) — keeps the lookup off the sign-in path.
let existingAccount: { userId: string; hasPassword: boolean } | null = null;
if (link !== 'true' && !intent.userId && settings.allowRegister && intent.draft?.email) {
const existing = await provider.findUser(intent.draft.email, organization);
if (existing) {
if (link !== 'true' && !intent.userId && creationAllowed && intent.draft?.email) {
let existingId: string | undefined;
if (allowlisted) {
const plan = await resolveAllowlistedRegistration(provider, {
email: intent.draft.email,
targetOrg: callbackOrg,
aliasTag: env.IDP_AUTO_CREATE_ALIAS_TAG,
});
if (plan.kind === 'existing') existingId = plan.userId;
else ({ email: registerEmail, aliased } = plan);
} else {
existingId = (await provider.findUser(intent.draft.email, organization))?.id;
}
if (existingId) {
// hasPassword only changes the decision when auto-link is enabled; skip the extra RPC
// when ALLOW_IDP_AUTO_LINK is off (existence alone yields the account-exists hard error).
const hasPassword = allowAutoLink
? (await provider.listAuthMethods(existing.id)).includes('password')
? (await provider.listAuthMethods(existingId)).includes('password')
: false;
existingAccount = { userId: existing.id, hasPassword };
existingAccount = { userId: existingId, hasPassword };
}
}

Expand All @@ -230,7 +262,7 @@ export async function processIdpCallback(
intent,
link: link === 'true',
sessionUserId,
creationAllowed: settings.allowRegister,
creationAllowed,
existingAccount,
linkEmailOwnerUserId,
allowAutoLink,
Expand Down Expand Up @@ -474,7 +506,8 @@ export async function processIdpCallback(
// so addHumanUser always receives a concrete org. Raw organization is undefined on a
// bare (no ?organization=) flow, which causes Zitadel's FAILED_PRECONDITION.
const result = await registerAndLinkIdp(provider, entries, {
email: decision.draft.email ?? '',
// registerEmail is the `+<tag>` alias on the allow-list path (ADR 007), else the IdP email.
email: registerEmail ?? decision.draft.email ?? '',
firstName,
lastName,
organization: callbackOrg,
Expand All @@ -488,6 +521,14 @@ export async function processIdpCallback(
userAgent: userAgentFromRequest(request, fingerprintId),
deviceTrackingToken,
});
// Audit the created account (no email — PII guard). viaDomainAllowlist/aliased are the
// TEMPORARY ADR 007 fields; they read false everywhere the flag is unset.
logAuthEvent('idp.register', 'success', {
requestId,
idpId: decision.link.idpId,
viaDomainAllowlist: allowlisted === true,
aliased,
});
const lastUsedCookie = await serializeLastUsedLogin(`idp:${decision.link.idpId}`);
const passkeyHintCookie = result.loginName
? await serializePasskeyHint(result.loginName)
Expand Down
28 changes: 28 additions & 0 deletions app/server/infra/env.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,24 @@ const schema = z
.string()
.optional()
.transform((v) => v === 'true'),
// TEMPORARY (staging dual-org interim) — see app/resources/sso/idp-auto-create-allowlist.ts
// and ADR 007. Comma-separated email domains whose IdP-VERIFIED identities may auto-create a
// user in an org whose login policy disallows registration. Unset/empty (the default, and
// production) ⇒ fully off: the SSO callback runs its pre-existing logic untouched.
IDP_AUTO_CREATE_EMAIL_DOMAINS: z.string().optional(),
// TEMPORARY — the org id(s) the door applies to (comma-separated). REQUIRED alongside the
// domain list: the callback's target org can fall back to the raw ?organization= query param,
// so without this pin any registration-off org on the instance would become a valid
// self-provisioning target. Unset ⇒ the feature is off even if the domain list is set.
IDP_AUTO_CREATE_ORGS: z.string().optional(),
// TEMPORARY — companion to the above: the `+<tag>` inserted into the local part when the
// email already owns a user in ANOTHER org (Zitadel usernames are instance-unique).
// Defaults to 'staff'. Restricted to local-part-safe characters so a misconfigured value
// fails at boot instead of as an opaque registration error.
IDP_AUTO_CREATE_ALIAS_TAG: z
.string()
.regex(/^[A-Za-z0-9._-]*$/, 'IDP_AUTO_CREATE_ALIAS_TAG: use letters, digits, . _ - only')
.optional(),
// CSP `frame-ancestors` override. Unset ⇒ 'none' (secure default — the auth UI is
// not embeddable; X-Frame-Options: DENY is kept in lock-step). Set to a space/comma-
// separated allowlist of full origins (e.g. "https://staging.portal.example.com") in
Expand Down Expand Up @@ -284,6 +302,16 @@ const schema = z
.filter(Boolean)
: [],
AUTH_EMAIL_VERIFICATION_REQUIRED: requireEmailVerification,
// TEMPORARY (ADR 007): parsed allow-list (lower-cased, empty = off) and alias tag.
IDP_AUTO_CREATE_EMAIL_DOMAINS: (v.IDP_AUTO_CREATE_EMAIL_DOMAINS ?? '')
.split(/[,\s]+/)
.map((d) => d.trim().toLowerCase())
.filter(Boolean),
IDP_AUTO_CREATE_ORGS: (v.IDP_AUTO_CREATE_ORGS ?? '')
.split(/[,\s]+/)
.map((o) => o.trim())
.filter(Boolean),
IDP_AUTO_CREATE_ALIAS_TAG: (v.IDP_AUTO_CREATE_ALIAS_TAG ?? '').trim() || 'staff',
// SENTRY_TRACES_SAMPLE_RATE was already transformed to a number by the .pipe() above;
// preserve the already-parsed value (spread covers it from `v`).
// PUBLIC_ORIGIN: no default — carried through by `...v`. It is `undefined` only in
Expand Down
Loading
Loading