From 237f43e7703f7eff25cf1448de51937069e64cad Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 09:20:10 +0900 Subject: [PATCH 1/2] fix: strip Keycloak OIDC callback params from the post share link PostDetailPopup built its permanent link from window.location.href verbatim, so sharing right after (or during) a Keycloak sign-in redirect copied the raw code/state/session_state/iss query params along with the post link instead of a clean permalink. Add stripOidcCallbackParams (oidcReturnUrl.ts) and use it before appending ?post=, with unit tests covering both the strip and the no-op case. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01GUcvhvimNVGqjFuD9f1fUa --- frontend/src/App.tsx | 3 ++- frontend/src/oidcReturnUrl.test.ts | 26 ++++++++++++++++++++++++++ frontend/src/oidcReturnUrl.ts | 12 ++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6aa467231..fc06e9156 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -95,7 +95,7 @@ import { PostBody } from "./PostBody"; import { decodeHtmlEntities } from "./postBodyDisplay"; import { FiveW1H } from "./components/FiveW1H"; import { subgraphForPost } from "./lineageLayout"; -import { rememberOidcReturnUrl, returnUrlFromLocation } from "./oidcReturnUrl"; +import { rememberOidcReturnUrl, returnUrlFromLocation, stripOidcCallbackParams } from "./oidcReturnUrl"; import { isSupportedLocale, LOCALE_LABELS, @@ -1829,6 +1829,7 @@ function PostDetailPopup({ const permanentLink = (() => { const url = new URL(window.location.href); + stripOidcCallbackParams(url); url.searchParams.set("post", postId); url.hash = ""; return url.toString(); diff --git a/frontend/src/oidcReturnUrl.test.ts b/frontend/src/oidcReturnUrl.test.ts index de6e95502..02acbadce 100644 --- a/frontend/src/oidcReturnUrl.test.ts +++ b/frontend/src/oidcReturnUrl.test.ts @@ -3,6 +3,7 @@ import { rememberOidcReturnUrl, restoreOidcReturnUrl, returnUrlFromLocation, + stripOidcCallbackParams, } from "./oidcReturnUrl"; describe("OIDC return URL handling", () => { @@ -67,3 +68,28 @@ describe("OIDC return URL handling", () => { expect(window.localStorage.getItem("lineageweave.oidc.returnUrl")).toBeNull(); }); }); + +describe("stripOidcCallbackParams", () => { + it("removes the Keycloak auth-exchange params but keeps app deep-link params", () => { + const url = new URL( + "http://localhost:15173/?state=abc&session_state=def&iss=http%3A%2F%2Fidp&code=xyz&post=post-1&workspace=board", + ); + + stripOidcCallbackParams(url); + + expect(url.searchParams.get("state")).toBeNull(); + expect(url.searchParams.get("session_state")).toBeNull(); + expect(url.searchParams.get("iss")).toBeNull(); + expect(url.searchParams.get("code")).toBeNull(); + expect(url.searchParams.get("post")).toBe("post-1"); + expect(url.searchParams.get("workspace")).toBe("board"); + }); + + it("is a no-op when no OIDC params are present", () => { + const url = new URL("http://localhost:15173/?post=post-1"); + + stripOidcCallbackParams(url); + + expect(url.toString()).toBe("http://localhost:15173/?post=post-1"); + }); +}); diff --git a/frontend/src/oidcReturnUrl.ts b/frontend/src/oidcReturnUrl.ts index 027d2f55b..9e60890e4 100644 --- a/frontend/src/oidcReturnUrl.ts +++ b/frontend/src/oidcReturnUrl.ts @@ -1,6 +1,18 @@ export const OIDC_RETURN_URL_STORAGE_KEY = "lineageweave.oidc.returnUrl"; const MAX_OIDC_RETURN_URL_LENGTH = 4096; +/** Authorization-code response params Keycloak appends to the redirect URI + * (RFC 6749 sec. 4.1.2; `session_state` per OIDC Session Management). Any + * link built from `window.location` must strip these -- they're a one-time + * auth exchange, never part of a shareable URL. */ +const OIDC_CALLBACK_PARAMS = ["code", "state", "session_state", "iss"] as const; + +/** Removes OIDC callback artifacts from `url` in place -- call before turning + * `window.location` into a link a user can copy or share. */ +export function stripOidcCallbackParams(url: URL): void { + OIDC_CALLBACK_PARAMS.forEach((param) => url.searchParams.delete(param)); +} + type UrlLike = Pick; function isSafeReturnUrl(value: string): boolean { From dc1aaeecd4bfdba335ab1d36eb81286189b0424a Mon Sep 17 00:00:00 2001 From: seonghobae Date: Mon, 24 Aug 2026 18:40:04 +0900 Subject: [PATCH 2/2] fix(oidc): strip callback params from restored return URLs too stripOidcCallbackParams only cleaned share links, but restoreOidcReturnUrl falls back to returnUrlFromLocation() on the post-redirect location -- which still carries code/state/session_state/iss. Strip them at build time so no consumer of this module re-mints a URL with a one-time authorization code in it; regression test covers the redirect-shaped search string (devin review thread). --- frontend/src/oidcReturnUrl.test.ts | 11 +++++++++++ frontend/src/oidcReturnUrl.ts | 9 ++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/frontend/src/oidcReturnUrl.test.ts b/frontend/src/oidcReturnUrl.test.ts index 02acbadce..5693434b3 100644 --- a/frontend/src/oidcReturnUrl.test.ts +++ b/frontend/src/oidcReturnUrl.test.ts @@ -19,6 +19,17 @@ describe("OIDC return URL handling", () => { expect(returnUrlFromLocation({ pathname: "//evil.example", search: "", hash: "" })).toBe("/"); }); + it("strips OIDC callback params from a restored post-redirect location", () => { + // After Keycloak redirects back, window.location still carries the + // one-time code/state; a return URL built from it must not. + const cleaned = returnUrlFromLocation({ + pathname: "/", + search: "?post=abc&code=xyz&state=s&session_state=t&iss=i", + hash: "", + }); + expect(cleaned).toBe("/?post=abc"); + }); + it("restores an object or serialized OIDC state before storage fallback", () => { rememberOidcReturnUrl("/?post=stored-before-direct"); expect(restoreOidcReturnUrl("/?post=from-direct-state")).toBe( diff --git a/frontend/src/oidcReturnUrl.ts b/frontend/src/oidcReturnUrl.ts index 9e60890e4..5d478668c 100644 --- a/frontend/src/oidcReturnUrl.ts +++ b/frontend/src/oidcReturnUrl.ts @@ -24,7 +24,14 @@ function isSafeReturnUrl(value: string): boolean { } export function returnUrlFromLocation(location: UrlLike = window.location): string { - const value = `${location.pathname}${location.search}${location.hash}`; + // A restored return URL can itself be a post-redirect URL still carrying + // Keycloak callback artifacts (devin review thread on PR #576): strip + // them here too, so no consumer of this module re-mints a URL with a + // one-time authorization code in it. + const params = new URLSearchParams(location.search); + OIDC_CALLBACK_PARAMS.forEach((param) => params.delete(param)); + const cleanedSearch = params.toString(); + const value = `${location.pathname}${cleanedSearch ? `?${cleanedSearch}` : ""}${location.hash}`; return isSafeReturnUrl(value) ? value : "/"; }