Files
zitadel/apps/login/src/app/(login)/accounts/page.tsx
T
3c746281f1 fix(login): show account selection after RP-initiated logout (#12638)
**Supersedes #12471** by @conblem — same fix, rebased onto current
`main` with conflicts resolved and review feedback applied. Original
commits are preserved, all credit to @conblem.

Closes #12252

### Problem

After an RP-initiated logout (`end_session` with `id_token_hint`), Login
V2 redirected straight to `/loginname` instead of showing the account
picker like Login V1. The `sessions` cookie still referenced the
previous accounts, but their server-side sessions had been terminated,
so `listSessions` returned nothing and the flow treated the browser as
having no accounts at all.

### Solution

- `handleOIDCFlowInitiation` now checks the `sessions` cookie
(`hasCookieAccount`) when no live session is eligible for the request's
organization. If the cookie still knows an account and the RP passed no
`login_hint`, the user is sent to `/accounts` instead of `/loginname`.
`prompt=none` is excluded since it must never render UI.
- `/accounts` synthesizes an (invalid) `Session` for each cookie entry
that has no live counterpart, so the account is still listed and a click
re-authenticates via `/loginname` → password in one step.
- `clearSession` prunes the cookie entry when the server reports the
session as already gone (`NotFound` / `PermissionDenied`), so stale
cards can be dismissed. Any other error is rethrown so a still-valid
server session is never silently orphaned.
- `getAllSessionCookieIds` is now a thin wrapper over `getAllSessions`;
`FlowInitiationParams.sessionCookies` is typed as `Cookie[]`.
- Tests: new `describe` block covering the cookie fallback, `login_hint`
precedence, `prompt=none`, org-scope matching/mismatch, and
`SELECT_ACCOUNT` / default prompt with live sessions filtered out by
org.
- Also adds `.claude/`, `.cursor/`, `.codex/`, `.windsurf/`,
`.copilot/`, `.aider*` to `.gitignore`.

---------

Co-authored-by: conblem <[email protected]>
2026-08-27 11:07:30 +00:00

136 lines
4.9 KiB
TypeScript

import { DynamicTheme } from "@/components/dynamic-theme";
import { SessionsList } from "@/components/sessions-list";
import { Translated } from "@/components/translated";
import { getAllSessions } from "@/lib/cookies";
import { getServiceConfig } from "@/lib/service-url";
import { getBrandingSettings, getDefaultOrg, listSessions, ServiceConfig } from "@/lib/zitadel";
import { UserPlusIcon } from "@heroicons/react/24/outline";
import { create } from "@zitadel/client";
import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb";
import { Session, SessionSchema } from "@zitadel/proto/zitadel/session/v2/session_pb";
import { Metadata } from "next";
import { getTranslations } from "next-intl/server";
// import { getLocale } from "next-intl/server";
import { headers } from "next/headers";
import Link from "next/link";
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("accounts");
return { title: t("title") };
}
async function loadSessions({ serviceConfig, organization }: { serviceConfig: ServiceConfig; organization?: string }) {
// Deliberately no cleanup: expired cookie entries are still listed (as invalid
// accounts) so the user can re-authenticate with one click, like Login V1.
const sessionCookies = await getAllSessions();
if (!sessionCookies || !sessionCookies.length) {
console.info("No session cookie found.");
return [];
}
// listSessions is a plain search: ids of terminated or unknown sessions are
// simply absent from the response, it does not fail. Transport failures are
// deliberately not caught here, they must surface instead of downgrading
// live sessions to invalid cards.
const ids = sessionCookies.map((s) => s.id).filter((id) => !!id) as string[];
let liveSessions: Session[] = [];
if (ids.length) {
const response = await listSessions({ serviceConfig, ids });
liveSessions = response?.sessions ?? [];
}
// For cookie entries whose server-side session no longer exists (e.g. after
// an RP-initiated logout) synthesize an invalid Session so the account stays
// selectable and the user can re-authenticate with one click.
const liveIds = new Set(liveSessions.map((s) => s.id));
const synthesized: Session[] = sessionCookies
.filter((c) => !!c.id && !!c.loginName && !liveIds.has(c.id))
.map((c) =>
create(SessionSchema, {
id: c.id,
factors: {
user: {
loginName: c.loginName,
// No displayName: the cookie does not carry one, and reusing the
// loginName would render it twice and break the avatar initials.
organizationId: c.organization ?? "",
},
},
}),
);
let sessions = [...liveSessions, ...synthesized];
if (organization) {
sessions = sessions.filter((s) => s.factors?.user?.organizationId === organization);
}
return sessions;
}
export default async function Page(props: { searchParams: Promise<Record<string | number | symbol, string | undefined>> }) {
const searchParams = await props.searchParams;
const requestId = searchParams?.requestId;
const organization = searchParams?.organization;
const orgDomain = searchParams?.orgDomain;
const _headers = await headers();
const { serviceConfig } = getServiceConfig(_headers);
let defaultOrganization;
if (!organization) {
const org: Organization | null = await getDefaultOrg({ serviceConfig });
if (org) {
defaultOrganization = org.id;
}
}
let sessions = await loadSessions({ serviceConfig, organization });
const branding = await getBrandingSettings({ serviceConfig, organization: organization ?? defaultOrganization });
const params = new URLSearchParams();
if (requestId) {
params.append("requestId", requestId);
}
if (organization) {
params.append("organization", organization);
}
if (orgDomain) {
params.append("orgDomain", orgDomain);
}
return (
<DynamicTheme branding={branding}>
<div className="flex flex-col space-y-4">
<h1>
<Translated i18nKey="title" namespace="accounts" />
</h1>
<p className="ztdl-p">
<Translated i18nKey="description" namespace="accounts" />
</p>
</div>
<div className="w-full">
<div className="flex w-full flex-col space-y-2">
<SessionsList sessions={sessions} requestId={requestId} />
<Link href={`/loginname?` + params}>
<div className="flex flex-row items-center rounded-md px-4 py-3 transition-all hover:bg-black/10 dark:hover:bg-white/10">
<div className="mr-4 flex h-8 w-8 flex-row items-center justify-center rounded-full bg-black/5 dark:bg-white/5">
<UserPlusIcon className="h-5 w-5" />
</div>
<span className="text-sm">
<Translated i18nKey="addAnother" namespace="accounts" />
</span>
</div>
</Link>
</div>
</div>
</DynamicTheme>
);
}