diff --git a/CHANGELOG.md b/CHANGELOG.md index 2175bc4f7..72df03d06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/modules/i18n/locales/en.po b/app/modules/i18n/locales/en.po index 49b9f85a1..c540fd57e 100644 --- a/app/modules/i18n/locales/en.po +++ b/app/modules/i18n/locales/en.po @@ -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" @@ -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" @@ -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" @@ -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?" @@ -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" @@ -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" @@ -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." diff --git a/app/resources/authorize/authorize.service.ts b/app/resources/authorize/authorize.service.ts index abe8307ce..b0c75cb44 100644 --- a/app/resources/authorize/authorize.service.ts +++ b/app/resources/authorize/authorize.service.ts @@ -273,6 +273,7 @@ async function healIfSessionDead( requestId: string, rawId: string, nowMs: number, + organization: string | undefined, sleep: Sleep = realSleep ): Promise { let probe = await probeSession(provider, entry); @@ -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', { @@ -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 { 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:` 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 { try { const { callbackUrl } = await provider.createCallback(rawId, { @@ -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' }; } @@ -457,6 +504,11 @@ 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) { @@ -464,8 +516,19 @@ async function resolveOidc( 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=` onto a prompt=login request @@ -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, @@ -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') { diff --git a/app/resources/sso/sso-action.ts b/app/resources/sso/sso-action.ts index 3a01456f5..0bf9c2797 100644 --- a/app/resources/sso/sso-action.ts +++ b/app/resources/sso/sso-action.ts @@ -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'; @@ -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 ); @@ -153,7 +156,7 @@ 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()}` }; } @@ -161,7 +164,7 @@ export async function runSsoAction( const slug = payload.provider; const { success, failure } = idpReturnUrls(origin, slug, { link: payload.linkOnly === 'true', - organization: payload.organization || undefined, + organization, deviceTrackingToken: payload.deviceTrackingToken, }); @@ -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 diff --git a/app/resources/sso/sso-link.ts b/app/resources/sso/sso-link.ts index 7a3e2d0bb..e90ae1439 100644 --- a/app/resources/sso/sso-link.ts +++ b/app/resources/sso/sso-link.ts @@ -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'; @@ -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 diff --git a/app/resources/sso/sso-management.ts b/app/resources/sso/sso-management.ts index 5699b5140..c759e6214 100644 --- a/app/resources/sso/sso-management.ts +++ b/app/resources/sso/sso-management.ts @@ -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 ───────────────────────────────────────────────────────────────── @@ -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; } /** @@ -135,15 +139,15 @@ export async function resolveSsoManagement( csrf: { token: string; setCookie: string | null } ): Promise { 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> | null; @@ -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, }; diff --git a/app/resources/sso/sso-org.ts b/app/resources/sso/sso-org.ts new file mode 100644 index 000000000..77f52de82 --- /dev/null +++ b/app/resources/sso/sso-org.ts @@ -0,0 +1,20 @@ +// app/resources/sso/sso-org.ts +// +// Org precedence for the /sso management screen and its start-link action: an explicit org +// (URL `?organization=` on the loader, the form field on the action) wins; else the org the +// active session was minted under; else undefined so `resolveOrg` applies the default-org +// fallback (env pin → instance Default Organization). +// +// The session rung is what makes a BARE /id/sso link work for portals that sign users in under +// their own org (the staff portal pins its Zitadel org via the OIDC org-id scope, and that org is +// stamped onto the session entry at sign-in). Without it the screen listed the DEFAULT org's IdPs +// and linked the wrong Google provider. The loader and the action MUST resolve the same org — +// the form the loader renders is what the action reads — so both go through this one helper. +import type { SessionEntry } from '@/modules/auth/session/cookie'; + +export function resolveSsoOrg( + explicit: string | undefined, + entry: SessionEntry | undefined +): string | undefined { + return explicit || entry?.organization || undefined; +} diff --git a/app/routes/sso/index.tsx b/app/routes/sso/index.tsx index 2da1c09fc..36b8083ac 100644 --- a/app/routes/sso/index.tsx +++ b/app/routes/sso/index.tsx @@ -123,7 +123,8 @@ function UnlinkConfirmDialog({ } export default function SsoPage() { - const { csrfToken, loginName, linked, linkable, allowUnlink } = useLoaderData(); + const { csrfToken, loginName, linked, linkable, allowUnlink, organization } = + useLoaderData(); return ( {/* RRForm: auto-adds ?index → posts to the sso index action. */} - + {/* organization: the org the loader listed these IdPs under (URL param, else + the session's org) — the action re-resolves the provider against the same + list, so it must post back. Omitted when the default-org fallback applied. */} + diff --git a/app/server/observability.ts b/app/server/observability.ts index af2dd6abe..57b4d460f 100644 --- a/app/server/observability.ts +++ b/app/server/observability.ts @@ -114,7 +114,7 @@ export const auditSink: LogSink = (line) => console.log(line); // mfa_enroll mfa_enroll_challenge mfa_skip // mfa_passkey mfa_passkey_challenge mfa_u2f mfa_u2f_challenge // account_switch account_remove -// authrequest_resolve oidc_callback saml_response +// authrequest_resolve oidc_callback session_stale session_org_mismatch saml_response // device_code_lookup device_authorize // logout rate_limit // post_login_redirect post_login_settings post_login_admin_check diff --git a/cypress/component/resources/authorize/org-scoped-self-heal.cy.ts b/cypress/component/resources/authorize/org-scoped-self-heal.cy.ts new file mode 100644 index 000000000..f58eff467 --- /dev/null +++ b/cypress/component/resources/authorize/org-scoped-self-heal.cy.ts @@ -0,0 +1,174 @@ +// cypress/component/resources/authorize/org-scoped-self-heal.cy.ts +// +// Regression coverage for an ORG-PINNED auth request (`urn:zitadel:iam:org:id:` scope) that +// reaches the session-reuse branch of /authorize with a cookie session it cannot finalize. +// +// Observed in staging (auth-ui#140 thread): the staff portal pins its login to a Zitadel org. +// After the staff portal's RP-initiated logout, auth-ui's `sessions` cookie still holds the dead +// entry, so the next org-pinned authorize self-healed to `/login?requestId=…` and DROPPED the +// organization. The login page then rendered the DEFAULT org's IdPs for a request Zitadel would +// only ever finalize on the pinned org. Clearing cookies "fixed" it because the no-session branch +// (decideAuthorize) threads the org, while the heal path rebuilt the URL from scratch. +// +// The third case is a CROSS-ORG session: a live session whose user belongs to a different org +// than the request pins. Zitadel rejects that callback with FAILED_PRECONDITION +// (Errors.User.NotAllowedOrg), indistinguishable BY CODE from the stale-grant case that +// stale-grant-callback.cy.ts covers — so a perfectly valid session used to be pruned as "stale". +// The org is now checked BEFORE createCallback: a mismatch routes to /login on the pinned org, +// leaves the session intact, and emits a distinct session_org_mismatch event. +// +// Node-bound (real signed `sessions` cookie on the Request) → cy.task node-spec harness. +import { callService, type AuditEvent } from '../../../support/node/call-service'; + +const ORG = '777001'; // Zitadel org ids are numeric — the org-id scope regex only accepts digits +const ORG_SCOPE = `urn:zitadel:iam:org:id:${ORG}`; +const SESSION = { id: 'sess-org-1', token: 'tok-org-1' }; +const COOKIE = [{ id: SESSION.id, token: SESSION.token, loginName: 'alice@acme.test' }]; +const OTHER_ORG_USER = { id: 'u-alice', loginName: 'alice@acme.test', orgId: '777002' }; + +function orgScopedSeed() { + return { + authRequests: { + req1: { id: 'req1', clientId: 'client1', scopes: [ORG_SCOPE], prompt: [] }, + }, + }; +} + +function has(audit: AuditEvent[], event: string, outcome?: 'success' | 'failure') { + return audit.find((e) => e.event === event && (outcome === undefined || e.outcome === outcome)); +} + +/** + * True when the cookie was not rewritten, or was rewritten with the entry still in it. + * The harness sets `cookieEntries` to null ONLY when the response carried no `sessions=` + * Set-Cookie (harness.ts). The request's own cookie already holds SESSION.id, so "no rewrite" + * means the browser keeps sending the original entry — i.e. nothing was pruned. + */ +function sessionKept(v: { response?: { cookieEntries?: Array<{ id: string }> | null } }) { + const entries = v.response?.cookieEntries; + return entries == null || entries.some((e) => e.id === SESSION.id); +} + +describe('resolveOidc — org-pinned auth request meets a cookie session it cannot reuse', () => { + it('dead session (getSession→null): the /login self-heal keeps the organization from the scope', () => { + callService({ + fn: 'resolveAuthorize', + provider: 'fresh', + seed: orgScopedSeed(), + sessionResults: { [SESSION.id]: { mode: 'null' } }, + request: { + url: 'http://localhost/id/authorize?authRequest=req1', + sessions: COOKIE, + }, + }).then((v) => { + expect(v.response?.status).to.equal(302); + const loc = v.response?.location ?? ''; + expect(loc).to.include('/login'); + expect(loc).to.include('requestId=oidc_req1'); + expect(loc, 'organization threaded through the self-heal').to.include(`organization=${ORG}`); + expect(loc).to.not.include('/error'); + // Same heal shape as logout.cy.ts: the dead entry is pruned and the heal is traceable. + expect(v.response?.cookieEntries?.some((e) => e.id === SESSION.id) ?? false).to.equal(false); + expect(has(v.audit, 'session_stale', 'success')?.sessionId).to.equal(SESSION.id); + }); + }); + + it('stale grant (createCallback→FAILED_PRECONDITION): the /login self-heal keeps the organization', () => { + callService({ + fn: 'resolveAuthorize', + provider: 'fresh', + seed: orgScopedSeed(), + liveSessions: [SESSION], + callbackResults: { [SESSION.id]: { mode: 'throw', code: 'FAILED_PRECONDITION' } }, + request: { + url: 'http://localhost/id/authorize?authRequest=req1', + sessions: COOKIE, + }, + }).then((v) => { + expect(v.response?.status).to.equal(302); + const loc = v.response?.location ?? ''; + expect(loc).to.include('/login'); + expect(loc).to.include('requestId=oidc_req1'); + expect(loc, 'organization threaded through the self-heal').to.include(`organization=${ORG}`); + expect(loc).to.not.include('/error'); + expect(v.response?.cookieEntries?.some((e) => e.id === SESSION.id) ?? false).to.equal(false); + expect(has(v.audit, 'session_stale', 'success')?.sessionId).to.equal(SESSION.id); + }); + }); + + it('live session in ANOTHER org: routes to /login on the pinned org WITHOUT calling createCallback or pruning the session', () => { + callService({ + fn: 'resolveAuthorize', + provider: 'fresh', + seed: orgScopedSeed(), + liveSessions: [{ ...SESSION, user: OTHER_ORG_USER }], + request: { + url: 'http://localhost/id/authorize?authRequest=req1', + sessions: COOKIE, + }, + }).then((v) => { + expect(v.response?.status).to.equal(302); + const loc = v.response?.location ?? ''; + expect(loc).to.include('/login'); + expect(loc).to.include('requestId=oidc_req1'); + expect(loc, 'login page pinned to the request org').to.include(`organization=${ORG}`); + expect(loc).to.not.include('/error'); + expect(loc).to.not.include('client.acme.test/callback'); + // The session is valid for other clients (the cloud portal): it must NOT be pruned, and + // must NOT be mistaken for the stale-grant case. + expect(sessionKept(v), 'session left intact').to.equal(true); + expect(has(v.audit, 'session_stale'), 'no session_stale').to.equal(undefined); + // createCallback was never attempted — no oidc_callback success OR failure. + expect(has(v.audit, 'oidc_callback'), 'no oidc_callback event').to.equal(undefined); + const mismatch = has(v.audit, 'session_org_mismatch', 'success'); + expect(mismatch !== undefined, 'session_org_mismatch event').to.equal(true); + expect(mismatch?.sessionId).to.equal(SESSION.id); + expect(mismatch?.requestId).to.equal('req1'); + }); + }); + + it('live session in ANOTHER org handed back explicitly (?sessionId=): same /login-on-pinned-org outcome, session intact', () => { + callService({ + fn: 'resolveAuthorize', + provider: 'fresh', + seed: orgScopedSeed(), + liveSessions: [{ ...SESSION, user: OTHER_ORG_USER }], + request: { + url: `http://localhost/id/authorize?authRequest=req1&sessionId=${SESSION.id}`, + sessions: COOKIE, + }, + }).then((v) => { + expect(v.response?.status).to.equal(302); + const loc = v.response?.location ?? ''; + expect(loc).to.include('/login'); + expect(loc).to.include(`organization=${ORG}`); + expect(loc).to.not.include('client.acme.test/callback'); + expect(sessionKept(v), 'session left intact').to.equal(true); + expect(has(v.audit, 'oidc_callback'), 'no oidc_callback event').to.equal(undefined); + expect(has(v.audit, 'session_org_mismatch', 'success')?.sessionId).to.equal(SESSION.id); + }); + }); + + // Guard against over-blocking: with NO org scope (the cloud portal), Zitadel performs no org + // check, so a session from any org is reusable and the callback must still proceed. + it('no org scope: a live session from any org is reused and the callback proceeds', () => { + callService({ + fn: 'resolveAuthorize', + provider: 'fresh', + seed: { + authRequests: { req1: { id: 'req1', clientId: 'client1', scopes: [], prompt: [] } }, + }, + liveSessions: [{ ...SESSION, user: OTHER_ORG_USER }], + request: { + url: 'http://localhost/id/authorize?authRequest=req1', + sessions: COOKIE, + }, + }).then((v) => { + expect(v.response?.status).to.equal(302); + const loc = v.response?.location ?? ''; + expect(loc).to.include('client.acme.test/callback'); + expect(has(v.audit, 'session_org_mismatch'), 'no mismatch event').to.equal(undefined); + expect(has(v.audit, 'oidc_callback', 'success')?.sessionId).to.equal(SESSION.id); + }); + }); +}); diff --git a/cypress/component/resources/sso/sso-org-precedence.cy.ts b/cypress/component/resources/sso/sso-org-precedence.cy.ts new file mode 100644 index 000000000..af866bc02 --- /dev/null +++ b/cypress/component/resources/sso/sso-org-precedence.cy.ts @@ -0,0 +1,107 @@ +// cypress/component/resources/sso/sso-org-precedence.cy.ts +// +// /sso org precedence: URL `?organization=` first, then the session cookie entry's organization, +// then the shared default-org fallback (default-org-fallback.cy.ts covers that last rung). +// +// Why both rungs: the staff portal signs users in under its own Zitadel org (the OIDC org-id scope +// lands on the session entry), then sends them to a BARE /id/sso. Without the session rung the +// management screen listed the DEFAULT org's IdPs and the start-link action linked the wrong +// provider. Without the URL rung a caller could not point a user at a different org than the one +// their session was minted under. Node-bound: real signed `sessions` cookie + seeded fake provider. +import { callService } from '../../../support/node/call-service'; + +const RECORD = ['getActiveIdPs', 'startIdpIntent'] as const; +const USER = { id: 'u1', loginName: 'you@acme.test' }; +const LIVE = [{ id: 'sess-1', token: 'tok-1', user: USER }]; +const cookie = (organization?: string) => [ + { id: 'sess-1', token: 'tok-1', loginName: USER.loginName, organization }, +]; + +describe('/sso org precedence — URL param, then session entry, then default org', () => { + it('management loader: URL org wins over the session org, and is echoed for the forms', () => { + callService({ + fn: 'resolveSsoManagement', + provider: 'singleton', + liveSessions: LIVE, + request: { + url: 'http://localhost/id/sso?organization=org-url', + sessions: cookie('org-sess'), + }, + recordCalls: [...RECORD], + }).then((v) => { + expect(v.calls?.getActiveIdPs?.[0]?.[0], 'IdPs listed for the URL org').to.equal('org-url'); + expect(v.outcome.kind).to.equal('data'); + expect(v.outcome.data.organization, 'echoed so the start-link forms carry it').to.equal( + 'org-url' + ); + }); + }); + + it('management loader: falls back to the session entry org on a bare /sso', () => { + callService({ + fn: 'resolveSsoManagement', + provider: 'singleton', + liveSessions: LIVE, + request: { url: 'http://localhost/id/sso', sessions: cookie('org-sess') }, + recordCalls: [...RECORD], + }).then((v) => { + expect(v.calls?.getActiveIdPs?.[0]?.[0], 'IdPs listed for the session org').to.equal( + 'org-sess' + ); + expect(v.outcome.data.organization).to.equal('org-sess'); + }); + }); + + it('start-link action: form org wins over the session org', () => { + callService({ + fn: 'runSsoAction', + provider: 'singleton', + liveSessions: LIVE, + request: { + url: 'http://localhost/id/sso', + sessions: cookie('org-sess'), + form: { intent: 'start', provider: 'google', linkOnly: 'true', organization: 'org-form' }, + }, + recordCalls: [...RECORD], + }).then((v) => { + expect(v.calls?.getActiveIdPs?.[0]?.[0]).to.equal('org-form'); + const urls = v.calls?.startIdpIntent?.[0]?.[1] as { success: string }; + expect(urls.success, 'callback carries the form org').to.include('organization=org-form'); + }); + }); + + it('start-link action: falls back to the session entry org when the form has none', () => { + callService({ + fn: 'runSsoAction', + provider: 'singleton', + liveSessions: LIVE, + request: { + url: 'http://localhost/id/sso', + sessions: cookie('org-sess'), + form: { intent: 'start', provider: 'google', linkOnly: 'true' }, + }, + recordCalls: [...RECORD], + }).then((v) => { + expect(v.calls?.getActiveIdPs?.[0]?.[0]).to.equal('org-sess'); + const urls = v.calls?.startIdpIntent?.[0]?.[1] as { success: string }; + expect(urls.success, 'callback carries the session org').to.include('organization=org-sess'); + }); + }); + + it('/sso/link with a session and no provider forwards ?organization= to /sso', () => { + callService({ + fn: 'resolveSsoLink', + provider: 'singleton', + liveSessions: LIVE, + request: { + url: 'http://localhost/id/sso/link?organization=org-url', + sessions: cookie(undefined), + }, + }).then((v) => { + expect(v.outcome.kind).to.equal('redirect'); + expect(v.outcome.location, 'org survives the hop to the management screen').to.equal( + '/sso?organization=org-url' + ); + }); + }); +}); diff --git a/cypress/component/routes/sso/sso-render.cy.tsx b/cypress/component/routes/sso/sso-render.cy.tsx index 5fb88d8b9..97486f7c4 100644 --- a/cypress/component/routes/sso/sso-render.cy.tsx +++ b/cypress/component/routes/sso/sso-render.cy.tsx @@ -120,6 +120,34 @@ describe('SsoIndex — unlink guard: dialog confirm + disabled sole sign-in meth cy.get('button[type="submit"]').contains('Unlink').should('exist').and('not.be.disabled'); }); }); + +// ── sso/index — start-link forms carry the resolved org ─────────────────────── + +describe('SsoIndex — start-link forms thread the resolved organization', () => { + const linkable = [{ id: 'idp-g', name: 'Google', type: 'GOOGLE' }]; + const base = { + csrfToken: 'csrf-mgmt', + userId: 'u1', + loginName: 'you@acme.test', + linked: [], + linkable, + allowUnlink: false, + }; + + it('emits a hidden organization input on the start-link form when the loader resolved one', () => { + mountRoute(SsoIndex, 'sso-index', '/sso', '/sso', { ...base, organization: 'acme' }); + cy.get('input[name="intent"][value="start"]') + .closest('form') + .find('input[name="organization"]') + .should('have.value', 'acme'); + }); + + it('omits the organization input when the loader resolved none (default-org fallback)', () => { + mountRoute(SsoIndex, 'sso-index', '/sso', '/sso', { ...base, organization: undefined }); + cy.get('input[name="intent"][value="start"]').should('exist'); + cy.get('input[name="organization"]').should('not.exist'); + }); +}); // sso/provider/error's "Back to sign in" → bare /login assertion lived here too, but it was a // literal duplicate of provider-error-render.cy.tsx's "degrades to a bare /login when no // ceremony context is present" (same component, same expected href; only an unused `reason` diff --git a/cypress/support/node/scenario.ts b/cypress/support/node/scenario.ts index f700731cb..902876021 100644 --- a/cypress/support/node/scenario.ts +++ b/cypress/support/node/scenario.ts @@ -69,7 +69,7 @@ export interface ScenarioSeed { export interface LiveSessionSeed { id: string; token: string; - user?: { id: string; loginName: string; displayName?: string }; + user?: { id: string; loginName: string; displayName?: string; orgId?: string }; /** * Which authentication factors the seeded session carries, all stamped verified. * Defaults to ['password'] — the historical behavior, a fully authenticated session.