Skip to content

Commit 14b94e1

Browse files
committed
fix(auth-ui): resolve org first with default-org fallback (login shows org IdPs)
Datum's OIDC requests don't carry the org-id scope, and the rebuild resolved the login org only from that scope -> org came back undefined -> makeReqCtx(undefined) -> INSTANCE/default context -> /login rendered the instance duplicate IdPs instead of the Datum Cloud org IdPs (which hold the user<->idp links). Restore the old app's fallback: resolveOrg = urlOrg ?? ZITADEL_DEFAULT_ORG_ID ?? cached getDefaultOrg() (instance Default Organization = Datum Cloud); instance/default only as last resort. Threaded at the login loader and the /authorize decision (propagates organization into downstream ceremony redirects). New getDefaultOrg provider capability (+ fake); optional ZITADEL_DEFAULT_ORG_ID env pin.
1 parent d1dcbd9 commit 14b94e1

17 files changed

Lines changed: 452 additions & 17 deletions

File tree

app/modules/auth/auth-provider.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,13 @@ export interface AuthProvider {
117117
getPasswordComplexity(orgId?: string): Promise<PasswordComplexity | undefined>;
118118
getActiveIdPs(orgId?: string): Promise<IdProvider[]>; // P4
119119
// getLegalSupport removed — it had zero callers (dead port method).
120+
/**
121+
* The instance Default Organization id, or null when the provider exposes no default. Used by the
122+
* org-first / default-org fallback (`resolveOrg`) so a login without an explicit org still lands
123+
* on the real instance org's IdPs/branding instead of the INSTANCE/default context. The result is
124+
* stable for the life of the instance, so callers memoize it.
125+
*/
126+
getDefaultOrg(): Promise<string | null>;
120127

121128
// users
122129
findUser(identifier: string, orgId?: string): Promise<User | null>;

app/modules/auth/providers/fake/fake-provider.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,12 @@ interface Seed {
7171
idpIntents?: Record<string, IdpIntentResult>; // P4: pre-seeded intent results keyed by intentId
7272
settingsByOrg?: Record<string, Partial<LoginSettings>>; // P5: per-org setting overrides (e.g. forceMfa)
7373
orgDomains?: Record<string, string>; // P2 domain discovery: email domain → orgId
74+
/**
75+
* Instance Default Organization id returned by getDefaultOrg (the org-first / default-org
76+
* fallback). Defaults to a stable fake id ('org-default-fake'); seed `null` to exercise the
77+
* "no default org → INSTANCE/default context" last-resort branch.
78+
*/
79+
defaultOrgId?: string | null;
7480

7581
deviceAuths?: DeviceAuthSeed[]; // P6: device authorization requests keyed by userCode
7682
samlRequests?: SamlRequestSeed[]; // P6: SAML auth requests
@@ -104,6 +110,7 @@ export class FakeAuthProvider implements AuthProvider {
104110
private idpLinks = new Map<string, IdpLink[]>(); // P4: userId → links
105111
private settingsByOrg: Record<string, Partial<LoginSettings>>; // P5: per-org overrides
106112
private orgDomains: Record<string, string>; // P2 domain discovery: email domain → orgId
113+
private defaultOrgId: string | null; // instance Default Organization (org-first fallback)
107114
private enrolled = new Map<string, Set<AuthMethod>>(); // P5: dynamically enrolled methods (merged with seeded authMethods)
108115
private mfaSkippedAt = new Map<string, string>(); // P5: userId → ISO timestamp of last skip
109116
private issuedOtpEmailCodes = new Map<string, string>(); // sessionId → returnCode issued for that session
@@ -139,6 +146,8 @@ export class FakeAuthProvider implements AuthProvider {
139146
this.idpIntents = seed.idpIntents ?? {}; // P4
140147
this.settingsByOrg = seed.settingsByOrg ?? {}; // P5
141148
this.orgDomains = seed.orgDomains ?? {}; // P2 domain discovery
149+
// Stable fake default org unless the seed pins one (or `null` for the no-default branch).
150+
this.defaultOrgId = seed.defaultOrgId !== undefined ? seed.defaultOrgId : 'org-default-fake';
142151
this.deviceAuthSeeds = seed.deviceAuths ?? []; // P6
143152
this.samlRequestSeeds = seed.samlRequests ?? []; // P6
144153
this.ldapUserSeeds = seed.ldapUsers ?? []; // P6
@@ -192,6 +201,10 @@ export class FakeAuthProvider implements AuthProvider {
192201
return this.idps;
193202
}
194203

204+
async getDefaultOrg(): Promise<string | null> {
205+
return this.defaultOrgId;
206+
}
207+
195208
// ─── users ────────────────────────────────────────────────────────────────────
196209

197210
async findUser(identifier: string, _orgId?: string): Promise<User | null> {
@@ -631,6 +644,10 @@ export class FakeAuthProvider implements AuthProvider {
631644
setLoginDefaultRedirectUri(uri: string | undefined): void {
632645
this.loginDefaultRedirectUri = uri;
633646
}
647+
// Override the instance Default Organization getDefaultOrg returns (null ⇒ no default org).
648+
setDefaultOrg(id: string | null): void {
649+
this.defaultOrgId = id;
650+
}
634651
// P2 domain discovery: flip the BASE allowDomainDiscovery flag (getLoginSettings(undefined)).
635652
// Mirrors setLoginDefaultRedirectUri — a deterministic seam for the resolveIdentifier ON test,
636653
// since the instance/base policy (not a per-org override) governs whether discovery runs.

app/modules/auth/providers/zitadel/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@ export class ZitadelAuthProvider implements AuthProvider {
7777
getActiveIdPs(orgId?: string): Promise<IdProvider[]> {
7878
return settings.getActiveIdPs(this.ctx, orgId);
7979
}
80+
getDefaultOrg(): Promise<string | null> {
81+
return user.getDefaultOrg(this.ctx);
82+
}
8083

8184
// users
8285
findUser(identifier: string, orgId?: string): Promise<User | null> {

app/modules/auth/providers/zitadel/user.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,27 @@ export function findOrgByDomain(
7878
});
7979
}
8080

81+
export function getDefaultOrg(ctx: ZitadelCtx): Promise<string | null> {
82+
// Instance Default Organization lookup (org-first / default-org fallback). Filters
83+
// OrganizationService.ListOrganizations by the instance defaultQuery and returns the first
84+
// match's id. Reuses the same OrganizationService client as findOrgByDomain.
85+
// filter SearchQuery.query = { case: 'defaultQuery', value: {} } (OrganizationDefaultQuery)
86+
// response ListOrganizationsResponse { result: Organization[] } (Organization.id: string)
87+
const orgs = ctx.svc(OrganizationService);
88+
return ctx.call(async () => {
89+
const req = create(ListOrganizationsRequestSchema, {
90+
queries: [
91+
create(OrgSearchQuerySchema, {
92+
query: { case: 'defaultQuery', value: {} },
93+
}),
94+
],
95+
});
96+
const resp = await orgs.listOrganizations(req);
97+
const result = resp.result ?? [];
98+
return result[0]?.id ?? null;
99+
});
100+
}
101+
81102
export function getUser(ctx: ZitadelCtx, id: string): Promise<User | null> {
82103
const users = ctx.svc(UserService);
83104
return ctx.call(async () => {

app/modules/i18n/locales/en.po

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ msgstr "Additional verification is required to continue."
6464
msgid "Already have an account?"
6565
msgstr "Already have an account?"
6666

67-
#: app/routes/login/index.tsx:257
67+
#: app/routes/login/index.tsx:264
6868
msgid "An account with this email already exists — sign in to continue."
6969
msgstr "An account with this email already exists — sign in to continue."
7070

@@ -164,7 +164,7 @@ msgstr "Choose how to sign in"
164164
msgid "Choose how you want to verify your identity."
165165
msgstr "Choose how you want to verify your identity."
166166

167-
#: app/routes/login/index.tsx:253
167+
#: app/routes/login/index.tsx:260
168168
msgid "Choose your login method"
169169
msgstr "Choose your login method"
170170

@@ -186,7 +186,7 @@ msgid "Connected accounts"
186186
msgstr "Connected accounts"
187187

188188
#: app/routes/device/index.tsx:73
189-
#: app/routes/login/index.tsx:334
189+
#: app/routes/login/index.tsx:341
190190
#: app/routes/signup/index.tsx:269
191191
msgid "Continue"
192192
msgstr "Continue"
@@ -211,7 +211,7 @@ msgstr "Couldn't sign in"
211211
msgid "Create a new account"
212212
msgstr "Create a new account"
213213

214-
#: app/routes/login/index.tsx:365
214+
#: app/routes/login/index.tsx:372
215215
#: app/routes/signup/password.tsx:189
216216
msgid "Create account"
217217
msgstr "Create account"
@@ -233,8 +233,8 @@ msgstr "Device code"
233233
msgid "Device denied"
234234
msgstr "Device denied"
235235

236-
#: app/routes/login/index.tsx:210
237-
#: app/routes/login/index.tsx:307
236+
#: app/routes/login/index.tsx:217
237+
#: app/routes/login/index.tsx:314
238238
#: app/routes/signup/index.tsx:230
239239
#: app/routes/signup/index.tsx:255
240240
msgid "Email"
@@ -244,7 +244,7 @@ msgstr "Email"
244244
msgid "Email code"
245245
msgstr "Email code"
246246

247-
#: app/routes/login/index.tsx:343
247+
#: app/routes/login/index.tsx:350
248248
#: app/routes/login/method.tsx:113
249249
#: app/routes/signup/method.tsx:263
250250
msgid "Email me a sign-in link"
@@ -266,7 +266,7 @@ msgstr "Email OTP"
266266
msgid "Email sign-in isn't available — use your username."
267267
msgstr "Email sign-in isn't available — use your username."
268268

269-
#: app/routes/login/index.tsx:209
269+
#: app/routes/login/index.tsx:216
270270
msgid "Email, phone, or username"
271271
msgstr "Email, phone, or username"
272272

@@ -372,7 +372,7 @@ msgstr "No sign-in method is available for this account."
372372
msgid "No signed-in accounts."
373373
msgstr "No signed-in accounts."
374374

375-
#: app/routes/login/index.tsx:363
375+
#: app/routes/login/index.tsx:370
376376
msgid "Not registered?"
377377
msgstr "Not registered?"
378378

@@ -392,7 +392,7 @@ msgstr "or"
392392
msgid "Or import this URI in your authenticator app"
393393
msgstr "Or import this URI in your authenticator app"
394394

395-
#: app/routes/login/index.tsx:288
395+
#: app/routes/login/index.tsx:295
396396
#: app/routes/login/method.tsx:98
397397
#: app/routes/setup/mfa.tsx:46
398398
msgid "Passkey"
@@ -428,7 +428,7 @@ msgstr "Password must contain an uppercase letter."
428428
msgid "Password sign-in isn't available for this account."
429429
msgstr "Password sign-in isn't available for this account."
430430

431-
#: app/routes/login/index.tsx:212
431+
#: app/routes/login/index.tsx:219
432432
msgid "Phone"
433433
msgstr "Phone"
434434

@@ -565,7 +565,7 @@ msgstr "Sign in with LDAP"
565565
msgid "Sign out"
566566
msgstr "Sign out"
567567

568-
#: app/routes/login/index.tsx:353
568+
#: app/routes/login/index.tsx:360
569569
msgid "Sign-in is currently unavailable for this account. Please contact your administrator."
570570
msgstr "Sign-in is currently unavailable for this account. Please contact your administrator."
571571

@@ -687,7 +687,7 @@ msgstr "Use your passkey to verify your identity."
687687
msgid "Use your security key to verify your identity."
688688
msgstr "Use your security key to verify your identity."
689689

690-
#: app/routes/login/index.tsx:213
690+
#: app/routes/login/index.tsx:220
691691
#: app/routes/sso/ldap.tsx:73
692692
msgid "Username"
693693
msgstr "Username"
@@ -761,7 +761,7 @@ msgstr "We've sent a password reset link to <0>{0}</0>"
761761
msgid "We've sent a verification link to <0>{0}</0>"
762762
msgstr "We've sent a verification link to <0>{0}</0>"
763763

764-
#: app/routes/login/index.tsx:250
764+
#: app/routes/login/index.tsx:257
765765
msgid "Welcome"
766766
msgstr "Welcome"
767767

app/resources/authorize/authorize-decision.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,25 @@ export interface AuthorizeInput {
2424
authRequest: AuthRequest;
2525
hasSessions: boolean;
2626
validSessionId?: string;
27+
/**
28+
* The effective organization, pre-resolved by the impure caller (resolveOidc → resolveOrg:
29+
* scope-org → env pin → provider default org). When provided it OVERRIDES the scope-derived org,
30+
* so a default-org fallback threads `organization` into the /login (and /signup, /accounts)
31+
* redirect even when the OIDC request carried no org-id scope. Omitted ⇒ this stays pure and
32+
* derives the org from scopes exactly as before (unchanged behaviour for existing callers/tests).
33+
*/
34+
organization?: string;
2735
}
2836

2937
// target 'callback' means: call createCallback(validSessionId); 'error' carries .error.
3038
export function decideAuthorize({
3139
authRequest,
3240
hasSessions,
3341
validSessionId,
42+
organization,
3443
}: AuthorizeInput): AuthorizeDecision {
35-
const org = deriveOrganizationFromScopes(authRequest.scopes);
44+
// Pre-resolved org (default-org fallback) wins; else derive from scopes (legacy behaviour).
45+
const org = organization ?? deriveOrganizationFromScopes(authRequest.scopes);
3646
const baseParams = org ? { organization: org } : undefined;
3747

3848
if (authRequest.prompt.includes('create')) return { target: '/signup', params: baseParams };

app/resources/authorize/authorize.service.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,12 @@ import {
2121
type SessionEntry,
2222
} from '@/modules/auth/session/cookie';
2323
import { ProviderError, type Session } from '@/modules/auth/types';
24-
import { decideAuthorize } from '@/resources/authorize/authorize-decision';
24+
import {
25+
decideAuthorize,
26+
deriveOrganizationFromScopes,
27+
} from '@/resources/authorize/authorize-decision';
2528
import { primaryFresh } from '@/resources/shared/lifetimes';
29+
import { resolveOrg } from '@/resources/shared/resolve-org';
2630
import { logAuthEvent } from '@/server/observability';
2731
import { type AuthErrorCode, providerErrorCode } from '@/utils/errors/auth-error';
2832
import { redirect } from 'react-router';
@@ -338,10 +342,16 @@ async function resolveOidc(
338342
}
339343

340344
const recent = mostRecent(list);
345+
// Org-first / default-org fallback: when the OIDC request carries no org-id scope, resolve the
346+
// instance default org (env pin → provider default) so the bootstrap redirect (/login, /signup,
347+
// /accounts) still threads `organization` and the login renders the real org's IdPs instead of
348+
// the INSTANCE/default context. An explicit scope org always wins (resolveOrg is org-first).
349+
const organization = await resolveOrg(provider, deriveOrganizationFromScopes(authRequest.scopes));
341350
const decision = decideAuthorize({
342351
authRequest,
343352
hasSessions: list.length > 0,
344353
validSessionId: recent?.id,
354+
organization,
345355
});
346356

347357
if (decision.target === 'callback') {
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// app/resources/shared/resolve-org.ts
2+
//
3+
// Org-first, default-org fallback resolution for the login / authorize flows.
4+
//
5+
// Datum's OIDC requests do NOT carry the `urn:zitadel:iam:org:id:<id>` scope, and the rebuild
6+
// resolved the login org ONLY from that scope — so `org` came back undefined, makeReqCtx(undefined)
7+
// fell through to the INSTANCE/default context, and /login rendered the instance IdPs instead of the
8+
// Datum Cloud org IdPs. This restores the old app's `getDefaultOrg()` fallback: prefer an explicit
9+
// org (URL param / OIDC org-id scope), then an ops env pin, then the provider's instance Default
10+
// Organization. The INSTANCE/default context (undefined) remains ONLY as the last resort, when the
11+
// provider itself has no default org.
12+
import type { AuthProvider } from '@/modules/auth/auth-provider';
13+
import { env } from '@/server/infra/env.server';
14+
15+
// Module-level memo of the provider's instance Default Organization. It is STABLE for the life of
16+
// the process, so one lookup serves every request. A `null` result (provider returned no default)
17+
// is deliberately NOT cached — the next call retries, so a transient miss never pins us to the
18+
// instance/default context permanently.
19+
let cachedDefaultOrg: string | null = null;
20+
21+
/**
22+
* Memoized read of the provider's instance Default Organization. Only a NON-null id is cached; a
23+
* null result is returned but left uncached so the next call retries. The provider is injected
24+
* (not module-global) to keep this unit-testable.
25+
*/
26+
export async function getCachedDefaultOrg(provider: AuthProvider): Promise<string | null> {
27+
if (cachedDefaultOrg !== null) return cachedDefaultOrg;
28+
const resolved = await provider.getDefaultOrg();
29+
if (resolved !== null) cachedDefaultOrg = resolved;
30+
return resolved;
31+
}
32+
33+
/** Test-only: clear the module cache so precedence/caching specs start from a clean slate. */
34+
export function resetDefaultOrgCache(): void {
35+
cachedDefaultOrg = null;
36+
}
37+
38+
/**
39+
* Resolve the effective organization for a login/authorize flow — org-first, with a default-org
40+
* fallback:
41+
* 1. an explicit org (URL `?organization=` / OIDC org-id scope) always wins;
42+
* 2. else the `ZITADEL_DEFAULT_ORG_ID` env pin (ops override; no provider round-trip);
43+
* 3. else the provider's cached instance Default Organization;
44+
* 4. else `undefined` → INSTANCE/default context (last resort).
45+
*/
46+
export async function resolveOrg(
47+
provider: AuthProvider,
48+
urlOrg?: string
49+
): Promise<string | undefined> {
50+
return urlOrg ?? env.ZITADEL_DEFAULT_ORG_ID ?? (await getCachedDefaultOrg(provider)) ?? undefined;
51+
}

app/routes/login/index.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
isPhoneLike,
2222
makeLoginIdentifierClientSchema,
2323
} from '@/resources/login/login.schema';
24+
import { resolveOrg } from '@/resources/shared/resolve-org';
2425
import { paths } from '@/routes/paths';
2526
import { providerForRequest } from '@/server/auth-context.server';
2627
import { loaderCsrf, assertCsrf } from '@/server/csrf';
@@ -55,7 +56,13 @@ export async function loader({ request }: LoaderFunctionArgs) {
5556
return redirect(`${paths.authorize()}${url.search}`);
5657
}
5758

58-
const organization = url.searchParams.get('organization') ?? undefined;
59+
// Org-first with a default-org fallback: an explicit `?organization=` wins, else the env pin,
60+
// else the provider's instance Default Organization. Without this the org came back undefined and
61+
// the loader rendered the INSTANCE/default IdPs instead of the real org's IdPs.
62+
const organization = await resolveOrg(
63+
provider,
64+
url.searchParams.get('organization') ?? undefined
65+
);
5966
const [settings, branding, idps] = await Promise.all([
6067
provider.getLoginSettings(organization),
6168
provider.getBranding(organization),

app/server/infra/env.server.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ const schema = z
2929
// no extra headers. (Renamed from the fork's `CUSTOM_REQUEST_HEADERS` — Zitadel-only,
3030
// so it now carries the ZITADEL_ prefix like the other transport vars.)
3131
ZITADEL_CUSTOM_REQUEST_HEADERS: z.string().optional(),
32+
// Optional ops pin for the org-first / default-org fallback (resolveOrg). When set, a login
33+
// without an explicit `?organization=` (or OIDC org-id scope) uses THIS org id instead of
34+
// calling the provider's getDefaultOrg. Unset (default) ⇒ resolveOrg falls back to the
35+
// provider's instance Default Organization. No default value.
36+
ZITADEL_DEFAULT_ORG_ID: z.string().optional(),
3237
// Fallback destination after a standalone login (e.g. when no ?redirect param
3338
// is present). Optional — unset in dev/test; the route supplies its own default.
3439
DEFAULT_APP_URL: z.url().optional(),

0 commit comments

Comments
 (0)