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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Adding a sign-in method (passkey, security key, authenticator app, email or SMS
code) now identifies the account from the active session rather than by name, so
sessions created before this fix recover without signing in again.
- The Linked accounts page now keeps the organization it was opened for (URL
parameter, else the organization the session was signed in under) when listing
providers and starting a link, so users signed in under a non-default
organization link the right Google or GitHub provider instead of the default
organization's.
- A client pinned to one organization (the staff portal) no longer lands on the
default organization's login page after its own sign-out: the stale session is
cleared and the login page reopens on the pinned organization. A live session
from another organization is now turned away before the callback instead of
being discarded as stale.

## [0.1.0] — 2026-06-23

Expand Down
16 changes: 8 additions & 8 deletions app/modules/i18n/locales/en.po
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ msgstr "Authorize device"
msgid "Authorizing as"
msgstr "Authorizing as"

#: app/routes/sso/index.tsx:224
#: app/routes/sso/index.tsx:225
msgid "Available accounts to link"
msgstr "Available accounts to link"

Expand Down Expand Up @@ -208,7 +208,7 @@ msgstr "Confirm new password"
msgid "Confirm password"
msgstr "Confirm password"

#: app/routes/sso/index.tsx:149
#: app/routes/sso/index.tsx:150
msgid "Connected accounts"
msgstr "Connected accounts"

Expand Down Expand Up @@ -401,13 +401,13 @@ msgstr "Link expired"
msgid "Link your account"
msgstr "Link your account"

#: app/routes/sso/index.tsx:130
#: app/routes/sso/index.tsx:131
msgid "Linked accounts"
msgstr "Linked accounts"

#: app/routes/passkeys.tsx:248
#: app/routes/reauth.tsx:279
#: app/routes/sso/index.tsx:137
#: app/routes/sso/index.tsx:138
msgid "Logged in as"
msgstr "Logged in as"

Expand Down Expand Up @@ -462,7 +462,7 @@ msgstr "Not registered?"
#: app/routes/passkeys.tsx:249
#: app/routes/reauth.tsx:280
#: app/routes/signed-in.tsx:49
#: app/routes/sso/index.tsx:138
#: app/routes/sso/index.tsx:139
msgid "Not you?"
msgstr "Not you?"

Expand Down Expand Up @@ -843,7 +843,7 @@ msgstr "This directory account can't be linked here yet. Sign in with your passw
msgid "This helps us keep our platform stable by heading off fraud and abusive behavior."
msgstr "This helps us keep our platform stable by heading off fraud and abusive behavior."

#: app/routes/sso/index.tsx:188
#: app/routes/sso/index.tsx:189
msgid "This is your only sign-in method"
msgstr "This is your only sign-in method"

Expand Down Expand Up @@ -877,7 +877,7 @@ msgstr "Two-factor verification"

#: app/routes/sso/index.tsx:97
#: app/routes/sso/index.tsx:116
#: app/routes/sso/index.tsx:197
#: app/routes/sso/index.tsx:198
msgid "Unlink"
msgstr "Unlink"

Expand Down Expand Up @@ -997,7 +997,7 @@ msgstr "You are signed in"
msgid "You are signed in as"
msgstr "You are signed in as"

#: app/routes/sso/index.tsx:133
#: app/routes/sso/index.tsx:134
msgid "You can link multiple accounts to your Datum account."
msgstr "You can link multiple accounts to your Datum account."

Expand Down
96 changes: 83 additions & 13 deletions app/resources/authorize/authorize.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,7 @@ async function healIfSessionDead(
requestId: string,
rawId: string,
nowMs: number,
organization: string | undefined,
sleep: Sleep = realSleep
): Promise<AuthorizeOutcome | { session: Session }> {
let probe = await probeSession(provider, entry);
Expand All @@ -296,7 +297,7 @@ async function healIfSessionDead(
return { session: probe.session }; // caller proceeds to the freshness gate / createCallback
case 'confirmed-dead':
case 'dead-code':
return healStaleEntry(list, entry, requestId, rawId);
return healStaleEntry(list, entry, requestId, rawId, organization);
case 'transient':
// Transient/unknown: surface the friendly error path; NEVER self-heal, NEVER swallow.
logAuthEvent('oidc_callback', 'failure', {
Expand All @@ -309,29 +310,75 @@ async function healIfSessionDead(
}
}

/**
* The /login re-prompt for THIS auth request. Threads the explicit org (OIDC org-id scope) the
* same way decideAuthorize's bootstrap does: an org-pinned request (the staff portal) must land on
* the pinned org's login page, not the default org's. Rebuilding the URL from `requestId` alone
* silently dropped the org on every self-heal, which rendered the wrong org's IdPs for a request
* Zitadel would only ever finalize on the pinned org (auth-ui#140 thread).
*/
function loginRedirect(requestId: string, organization?: string): string {
const params = new URLSearchParams({ requestId });
if (organization) params.set('organization', organization);
return `/login?${params}`;
}

/** Drop the stale entry, re-prompt /login, and emit a traceable session_stale event. */
async function healStaleEntry(
list: SessionEntry[],
entry: SessionEntry,
requestId: string,
rawId: string
rawId: string,
organization?: string
): Promise<AuthorizeOutcome> {
logAuthEvent('session_stale', 'success', { requestId: rawId, sessionId: entry.id });
const pruned = removeSession(list, entry.id);
return {
kind: 'redirect',
location: `/login?requestId=${encodeURIComponent(requestId)}`,
location: loginRedirect(requestId, organization),
setCookie: await serializeSessions(pruned),
};
}

/**
* An org-pinned auth request (`urn:zitadel:iam:org:id:<id>` scope) can only be finalized by a
* session whose user belongs to that org: Zitadel's LinkSessionToAuthRequest rejects any other
* with FAILED_PRECONDITION (Errors.User.NotAllowedOrg) — the SAME code as the stale post-logout
* grant, so runCallback would prune a perfectly valid session as "stale" and heal-loop. Decide it
* here, before createCallback: a known, different user org means the session is fine for other
* clients (the un-pinned cloud portal) but not for this request. An unknown user org (session
* without a user factor yet) is left to Zitadel.
*/
function isOrgMismatch(session: Session, organization: string | undefined): boolean {
const userOrg = session.user?.orgId;
return organization !== undefined && userOrg !== undefined && userOrg !== organization;
}

/** Re-prompt /login on the pinned org, leaving the (valid) session untouched; traceable event. */
function rejectCrossOrgSession(
session: Session,
entry: SessionEntry,
requestId: string,
rawId: string,
organization: string
): AuthorizeOutcome {
logAuthEvent('session_org_mismatch', 'success', {
requestId: rawId,
sessionId: entry.id,
organization,
userOrg: session.user?.orgId,
});
return { kind: 'redirect', location: loginRedirect(requestId, organization) };
}

/** Run createCallback for a resolved live session and map success/failure to an outcome. */
async function runCallback(
provider: AuthProvider,
rawId: string,
entry: SessionEntry,
list: SessionEntry[],
requestId: string
requestId: string,
organization?: string
): Promise<AuthorizeOutcome> {
try {
const { callbackUrl } = await provider.createCallback(rawId, {
Expand All @@ -353,7 +400,7 @@ async function runCallback(
// other code (transient/unknown) keeps the conservative existing behavior — surface the error
// page rather than guessing that a re-login will help.
if (code && DEAD_CALLBACK_CODES.has(code)) {
return healStaleEntry(list, entry, requestId, rawId);
return healStaleEntry(list, entry, requestId, rawId, organization);
}
return { kind: 'error-redirect', code: 'signin_failed' };
}
Expand Down Expand Up @@ -457,15 +504,31 @@ async function resolveOidc(

const list = await readSessions(request);
const sessionId = url.searchParams.get('sessionId') ?? undefined;
// Explicit-only org threading: pass the org derived from the OIDC scope verbatim. The
// default-org fallback (env pin → provider default) is a /login display concern; threading
// it here caused users outside the default org to be hidden by the scoped findUser call.
// Derived up front so BOTH session-reuse paths below (and their self-heals) see it.
const organization = deriveOrganizationFromScopes(authRequest.scopes);

// explicit sessionId hand-back from /login/password → finish the callback
if (sessionId) {
const entry = byId(list, sessionId);
if (entry) {
// Validate liveness BEFORE reuse: a stale post-logout cookie self-heals to /login here
// instead of reaching createCallback on a terminated session (→ ALREADY_DONE → /error).
const gate = await healIfSessionDead(provider, list, entry, requestId, rawId, nowMs);
const gate = await healIfSessionDead(
provider,
list,
entry,
requestId,
rawId,
nowMs,
organization
);
if ('kind' in gate) return gate; // dead/transient → outcome already decided
if (organization && isOrgMismatch(gate.session, organization)) {
return rejectCrossOrgSession(gate.session, entry, requestId, rawId, organization);
}

// ANTI-FORGERY FRESHNESS GATE (prompt=login only). The sessionId is query-supplied, so a
// caller can forge `&sessionId=<their_own_STALE_live_session>` onto a prompt=login request
Expand All @@ -477,16 +540,12 @@ async function resolveOidc(
const mustReauth =
authRequest.prompt.includes('login') &&
!primaryFresh(gate.session.factors, nowMs, await freshLoginWindowMs(provider, entry));
if (!mustReauth) return runCallback(provider, rawId, entry, list, requestId);
if (!mustReauth) return runCallback(provider, rawId, entry, list, requestId, organization);
// else: stale prompt=login → do NOT finalize; fall through to decideAuthorize below.
}
}

const recent = mostRecent(list);
// Explicit-only org threading: pass the org derived from the OIDC scope verbatim. The
// default-org fallback (env pin → provider default) is a /login display concern; threading
// it here caused users outside the default org to be hidden by the scoped findUser call.
const organization = deriveOrganizationFromScopes(authRequest.scopes);
const decision = decideAuthorize({
authRequest,
hasSessions: list.length > 0,
Expand All @@ -501,9 +560,20 @@ async function resolveOidc(
// Validate liveness BEFORE reuse (same self-heal as the explicit-sessionId path above). No
// freshness gate here: for prompt=login decideAuthorize returns target '/login', never
// 'callback', so this branch is unreachable under prompt=login (only none/default reuse).
const healed = await healIfSessionDead(provider, list, entry, requestId, rawId, nowMs);
const healed = await healIfSessionDead(
provider,
list,
entry,
requestId,
rawId,
nowMs,
organization
);
if ('kind' in healed) return healed;
return runCallback(provider, rawId, entry, list, requestId);
if (organization && isOrgMismatch(healed.session, organization)) {
return rejectCrossOrgSession(healed.session, entry, requestId, rawId, organization);
}
return runCallback(provider, rawId, entry, list, requestId, organization);
}
if (decision.target === 'error') {
if (decision.error === 'NO_ACTIVE_SESSION') {
Expand Down
24 changes: 11 additions & 13 deletions app/resources/sso/sso-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { ssoErrorRedirect } from '@/resources/shared/next-step-params';
import { getActiveIdPs } from '@/resources/sso/idp-providers';
import { idpReturnUrls } from '@/resources/sso/idp-return-urls';
import { canUnlinkIdp } from '@/resources/sso/sso-management';
import { resolveSsoOrg } from '@/resources/sso/sso-org';
import type { SsoOutcome } from '@/resources/sso/sso-outcome';
import { trustedAppOrigin } from '@/server/infra/app-origin.server';
import { env } from '@/server/infra/env.server';
Expand Down Expand Up @@ -133,9 +134,11 @@ export async function runSsoAction(
}

// intent === 'start'
// Org-first / default-org fallback via the shared choke point: an explicit form `organization`
// wins; an empty one falls back to the default org (was undefined → the INSTANCE/default IdPs).
const activeIdPs = await getActiveIdPs(provider, payload.organization || undefined);
// Form org first, then the session entry's org, then the shared default-org fallback inside
// getActiveIdPs — the same precedence the /sso loader used to render this form (sso-org.ts), so
// the provider the user clicked resolves against the same IdP list it was listed from.
const organization = resolveSsoOrg(payload.organization, mostRecent(await readSessions(request)));
const activeIdPs = await getActiveIdPs(provider, organization);
const target = activeIdPs.find(
(p) => p.id === payload.provider || slugify(p.name) === payload.provider
);
Expand All @@ -153,15 +156,15 @@ export async function runSsoAction(
}

const qs = new URLSearchParams({ idpId: target.id });
if (payload.organization) qs.set('organization', payload.organization);
if (organization) qs.set('organization', organization);
return { kind: 'redirect', location: `/sso/ldap?${qs.toString()}` };
}

const origin = trustedAppOrigin(request);
const slug = payload.provider;
const { success, failure } = idpReturnUrls(origin, slug, {
link: payload.linkOnly === 'true',
organization: payload.organization || undefined,
organization,
deviceTrackingToken: payload.deviceTrackingToken,
});

Expand All @@ -181,16 +184,11 @@ export async function runSsoAction(
deps.onAuthEvent?.('idp_start', 'failure');
logAuthEvent('idp_start', 'failure', { reason: err.code });
// NOTE: this action's 'start' schema carries no requestId (the /sso management page's
// "start link" forms don't post one — see sso/index.tsx), so only organization threads
// here. organization is what's in scope (payload.organization); requestId stays absent.
// "start link" forms don't post one — see sso/index.tsx), so only the resolved organization
// threads here; requestId stays absent.
return {
kind: 'redirect',
location: ssoErrorRedirect(
slug,
providerErrorCode(err.code),
undefined,
payload.organization
),
location: ssoErrorRedirect(slug, providerErrorCode(err.code), undefined, organization),
};
}
throw err; // unknown → root ErrorBoundary
Expand Down
7 changes: 5 additions & 2 deletions app/resources/sso/sso-link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { readSessions, mostRecent } from '@/modules/auth/session/cookie';
import { ProviderError, type IdProvider } from '@/modules/auth/types';
import { getActiveIdPs } from '@/resources/sso/idp-providers';
import { idpReturnUrls } from '@/resources/sso/idp-return-urls';
import { paths } from '@/routes/paths';
import { trustedAppOrigin } from '@/server/infra/app-origin.server';
import { logAuthEvent } from '@/server/observability';

Expand Down Expand Up @@ -107,9 +108,11 @@ export async function resolveSsoLink(
};
}

// (b) Session, no provider → redirect to /sso management screen
// (b) Session, no provider → redirect to /sso management screen, keeping the org scope so the
// screen lists the same org's IdPs this link was entered with (the legacy /ui/v2/login/idp/link
// 301 lands here; a bare `/sso` here silently dropped the caller's org).
if (!wantedSlug) {
return { kind: 'redirect', location: '/sso' };
return { kind: 'redirect', location: paths.sso.index({ organization }) };
}

// (a) Session + specific provider → start link intent and redirect
Expand Down
17 changes: 11 additions & 6 deletions app/resources/sso/sso-management.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { readSessions, mostRecent } from '@/modules/auth/session/cookie';
import { ProviderError } from '@/modules/auth/types';
import type { AuthMethod, IdpLink, IdProvider } from '@/modules/auth/types';
import { getActiveIdPs } from '@/resources/sso/idp-providers';
import { resolveSsoOrg } from '@/resources/sso/sso-org';
import { env } from '@/server/infra/env.server';

// ── /sso loader ─────────────────────────────────────────────────────────────────
Expand All @@ -34,6 +35,9 @@ export interface SsoManagementData {
linked: LinkedIdpView[];
linkable: IdProvider[];
allowUnlink: boolean;
/** Org the IdP list was resolved under (URL param, else session org); absent on the default-org
* fallback. The route posts it back on every start-link form. */
organization?: string;
}

/**
Expand Down Expand Up @@ -135,15 +139,15 @@ export async function resolveSsoManagement(
csrf: { token: string; setCookie: string | null }
): Promise<SsoManagementResult> {
const url = new URL(request.url);
const organization = url.searchParams.get('organization') ?? undefined;

// Org-first / default-org fallback via the shared choke point: the /sso management screen must
// list + join against the org's IdPs (default org when no `?organization=`), not the INSTANCE set.
const active = await getActiveIdPs(provider, organization);

const entries = await readSessions(request);
const recent = mostRecent(entries);

// URL org first, then the org the session was minted under (see sso-org.ts), then the shared
// default-org fallback inside getActiveIdPs — the screen must list + join against THAT org's
// IdPs, not the INSTANCE set. Echoed in the data so the start-link forms post the same org.
const organization = resolveSsoOrg(url.searchParams.get('organization') ?? undefined, recent);
const active = await getActiveIdPs(provider, organization);

// Guard getSession so a transient ProviderError doesn't produce a raw 500.
// On any provider failure redirect to /login — the user must re-authenticate.
let session: Awaited<ReturnType<typeof provider.getSession>> | null;
Expand Down Expand Up @@ -183,6 +187,7 @@ export async function resolveSsoManagement(
// Multi on → offer every provider (add another); off → only providers with no link yet.
linkable: linkableProviders(active, linked, allowMulti),
allowUnlink: env.ALLOW_IDP_UNLINK,
organization,
},
setCookie: csrf.setCookie,
};
Expand Down
Loading
Loading