Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
0bd9ff5
fix(auth): add safe browser sign-in recovery
seonghobae Sep 21, 2026
d19da52
test(auth): reject failed OIDC callback fields in return URLs
seonghobae Sep 21, 2026
81f885d
fix(auth): strip OAuth error fields from retry return URLs
seonghobae Sep 21, 2026
05ea958
docs(auth): align OIDC deep-link ADR with callback scrubbing
seonghobae Sep 21, 2026
3bf1c37
test(auth): reject callback artifacts in persisted return URLs
seonghobae Sep 21, 2026
0a59cb3
fix(auth): sanitize persisted and state return URLs
seonghobae Sep 21, 2026
b536b11
docs(adr): sanitize every OIDC return-path admission
seonghobae Sep 21, 2026
ccdde2a
test(auth): preserve remembered deep link on retry
seonghobae Sep 21, 2026
192c78d
fix(auth): keep requested deep link across retry
seonghobae Sep 21, 2026
eddfed0
docs(adr): preserve pre-redirect path on OIDC retry
seonghobae Sep 21, 2026
5c648e2
test(auth): distinguish callback signal from lone state
seonghobae Sep 21, 2026
caa77b6
fix(auth): require real callback signal before storage precedence
seonghobae Sep 21, 2026
fa819f2
docs(adr): distinguish callback evidence from lone state
seonghobae Sep 21, 2026
ba951dd
test(auth): reject backslash authority return paths
seonghobae Sep 21, 2026
d310514
fix(auth): enforce same-origin return URL parsing
seonghobae Sep 21, 2026
2e7bd6c
docs(adr): pin parsed same-origin return URL boundary
seonghobae Sep 21, 2026
8081666
test(auth): require correlated callback evidence before storage prece…
seonghobae Sep 21, 2026
3873ef4
fix(auth): require correlated state before callback storage precedence
seonghobae Sep 21, 2026
3957423
docs(adr): require correlated state for callback precedence
seonghobae Sep 21, 2026
f82b384
test(auth): require primary OIDC response signal for retry precedence
seonghobae Sep 21, 2026
d0d2026
fix(auth): require primary OIDC response signal for retry precedence
seonghobae Sep 21, 2026
fca1b35
docs(adr): require primary OAuth outcome for retry precedence
seonghobae Sep 21, 2026
bc78292
test(auth): reject unsafe current-path fallback
seonghobae Sep 21, 2026
42614d4
fix(auth): sanitize final OIDC return fallback
seonghobae Sep 21, 2026
a5281e8
docs(adr): cover final OIDC history fallback
seonghobae Sep 21, 2026
5e734e3
fix(auth): contain sign-in recovery at narrow widths
seonghobae Sep 21, 2026
1f1d904
merge(auth): converge #1120 hardened test contracts
seonghobae Sep 21, 2026
6ed78b2
merge(auth): converge JWKS selector contract hardening
seonghobae Sep 21, 2026
c49aff3
chore(stack): converge #1124 on current #1120 authority
seonghobae Sep 22, 2026
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
82 changes: 81 additions & 1 deletion docs/adr/0109-oidc-deep-link-state-recovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,78 @@ tab's `sessionStorage` is not available. Falling back to `/` loses the post
deep link and presents the unauthenticated language/login surface again, even
when the member's OIDC session is otherwise valid.

The authorization endpoint can return either a successful code response or an
OAuth error response. Those response fields are one-time protocol artifacts,
not application navigation state. If a failed callback is turned back into a
remembered return URL, provider error fields can be replayed on the next
successful sign-in and can expose provider detail in a product-controlled URL.
A provider callback is also rooted at the configured redirect URI rather than
the original buyer deep link, so rebuilding retry state from the failed
callback alone can overwrite the path that was remembered before redirect.

The browser also accepts arbitrary product query parameters on the SPA root.
A response-shaped query name such as `code` or `error` therefore cannot, by
itself, prove that the current URL is the response to the OIDC transaction that
created remembered return-path storage. LineageWeave sends `state` on the
Authorization Code request; OAuth 2.0 and OpenID Connect require that value to
be returned on both success and error responses when it was present in the
request. A valid Authorization Code response also has a primary outcome member:
`code` for success or `error` for failure. `session_state`, `iss`,
`error_description`, and `error_uri` are ancillary metadata and cannot establish
a success or error response by themselves. Remembered-path precedence therefore
needs returned `state` plus `code` or `error`; either side alone is insufficient.

A lexical leading-slash check is not sufficient to prove that a candidate is a
same-origin path. WHATWG URL parsing treats backslashes as authority separators
for special schemes, so a value such as `/\\example.invalid/path` can begin
with a single slash yet parse to a different origin. Reconstructing only the
parsed pathname would then silently turn an external-shaped value into a new
local deep link rather than rejecting the invalid admission.

The same rule must also cover the final callback fallback. A same-origin document
can itself have a pathname beginning with `//`; returning that raw pathname to
`history.replaceState` makes it a protocol-relative URL candidate even though
it came from the current document. Bypassing the shared sanitizer at that last
fallback can therefore turn a harmless current path into a `SecurityError`
during callback cleanup and leave sign-in completion broken.

## Decision

- Keep the OIDC `state.returnUrl` as the first recovery source.
- Accept only a direct same-origin path, one bounded serialized object, or one
object value. Never recursively parse JSON-encoded strings; reject serialized
state and return paths longer than 4,096 characters before further handling.
- Validate return paths after WHATWG parsing against the product origin, not
only by string prefix. Reject any candidate whose parsed origin differs;
never strip an unexpected authority and re-mint only its pathname as local.
- Persist the same validated same-origin path in both `sessionStorage` and
`localStorage` before redirecting to OIDC. `localStorage` is only a bounded
recovery fallback, not an authentication or authorization store.
- On every return-path admission boundary — current browser location,
`state.returnUrl`, `sessionStorage`, and `localStorage` — and before writing a
return path back to storage, remove authorization-response artifacts: `code`,
`state`, `session_state`, `iss`, `error`, `error_description`, and
`error_uri`. Preserve unrelated same-origin product query parameters and the
fragment. This also cleans values persisted by an older client before this
boundary existed.
- When retrying while the browser is still on an OIDC success/error callback,
prefer the validated path remembered before redirect over the callback's
sanitized redirect-URI path only when `state` is present together with a
primary Authorization Code response member: `code` for success or `error`
for failure. `state` alone, `code`/`error` without `state`, and ancillary
metadata (`session_state`, `iss`, `error_description`, `error_uri`) without a
primary outcome are still scrubbed as reserved protocol data, but none of
those incomplete shapes authorizes stale browser storage to override current
product navigation. This follows RFC 6749 §§4.1.2 and 4.1.2.1: success
responses carry `code`, error responses carry `error`, and either response
returns `state` when the request supplied it. OpenID Connect metadata does
not replace that primary OAuth response member.
- Ordinary product navigation without correlated callback evidence continues
to derive its return path from the current location. Every value returned to
the History API, including the no-state/no-storage current-path fallback,
must pass the same bounded same-origin sanitizer; a protocol-relative or
otherwise inadmissible current pathname falls back to `/` rather than being
returned raw.
- On callback, remove the key from both stores and use session storage before
local storage. Reject external and protocol-relative URLs.
- Keep member language preference account-scoped in
Expand All @@ -30,6 +93,23 @@ when the member's OIDC session is otherwise valid.
## Consequences

Opening a shared post link survives a missing OIDC state payload or a changed
storage context without losing the post. A stale internal return path is
storage context without losing the post. A failed provider callback no longer
replaces the pre-redirect deep link with `/` merely because the callback was
rooted at the redirect URI, while incomplete response-shaped query combinations
cannot make stale return-path storage win over current product navigation. A
remembered path is preferred only for the correlated `state + code` or
`state + error` shapes. Successful and failed authorization response fields are
not minted into a later product return path, including when an older
stored/state value is recovered. Inputs that only look path-relative before
parsing but resolve to another origin are rejected instead of being host-stripped
into a different local path. The final current-path fallback is subject to the
same admission rule, so a `//...` pathname cannot escape as a protocol-relative
History API target and break callback cleanup. A stale internal return path is
removed at callback, and authorization still comes only from the authenticated
OIDC token and backend ABAC checks.

## References

Hardt, D. (2012). *The OAuth 2.0 authorization framework* (RFC 6749). Internet Engineering Task Force.

OpenID Foundation. (2014). *OpenID Connect Core 1.0 incorporating errata set 2*.
1 change: 1 addition & 0 deletions docs/storybook-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ operator-facing control you can click before changing product CSS.
| `Lineage/LineageDag` | Open a reconstructed connection to read its inferred channel scores and Allen interval relation, or open the current branch node; compare empty, single-branch, grouped/forked, mobile-scroll, ungrouped, and long-title states before changing graph CSS. On narrow viewports, swipe the named viewport or focus it and use arrow keys to inspect the full lineage. | `--color-accent-background`, `--radius-control`, `--surface`, `--border`, `--color-focus-border`, `--size-control-min`, `LineageDag` |
| `Chrome/StatusNotice` | Read success, unavailable, or retry copy, then take the named next action. Success and unavailable are a named region (not live `role=status`); Retry is `role=alert` and only on the retry kind. Calendar's missing Naruon projection uses unavailable. | `--badge-status-success-*`, `--badge-status-pending-*`, `--badge-status-danger-*`, `StatusNotice` |
| `Chrome/PopupCloseButton` | Close the evidence panel or post popup. | `--space-close-inset`, `--font-size-close`, `PopupCloseButton` |
| `Chrome/Sign-in recovery` | Restart browser sign-in after an invalid or expired callback without exposing identity-provider details. `Desktop` and `NarrowViewport` cover both supported viewport classes. | `login-card`, `error`, `btn-primary`, `SignInRecovery` |
| `Workspace/WorkspaceCalendar` | Read observed Naruon events, or open a commitment to land on that post. Fail-closed copy stays `이 범위의 일정을 아직 받을 수 없습니다`. | `--color-chip-border`, `WorkspaceCalendar`, `EvidenceStatusMark` |
| `Ask Agent/Public claim verification` | Compare supported, refuted, and not-enough-information states; open only the external evidence link, then review the separate internal citation before changing governed graph state. | `--space-panel-block`, `--space-control-gap`, `--color-border`, `--size-control-min`, `PublicClaimVerification` |
| `Ask Agent/Knowledge cutoff` | Exercise partial historical grounding, retained-revision provenance, later-live-change disclosure, and the narrow viewport before relying on a historical answer. | Native `datetime-local`, `--space-panel-block`, `--space-control-gap`, `--color-border`, `--size-control-min` |
Expand Down
33 changes: 30 additions & 3 deletions frontend/e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,34 @@
import { expect, test } from "@playwright/test";
import { loginAsDemoAnalyst } from "./support/auth.ts";

test("logs in and reaches an authenticated destination", async ({ page }) => {
await loginAsDemoAnalyst(page);
await expect(page.getByRole("button", { name: "Ask Agent" })).toBeVisible();
test("logs in with PKCE, restores the requested URL, and reaches a protected destination", async ({ page }, testInfo) => {
const protectedResponse = page.waitForResponse((response) =>
response.url().endsWith("/api/me") && response.request().method() === "GET"
);
await loginAsDemoAnalyst(page, "/?auth_return=evidence#workspace");
await expect(page).toHaveURL(/\/?auth_return=evidence#workspace$/);
expect((await protectedResponse).ok()).toBe(true);
await expect(page.getByRole("button", { name: "Log out" })).toBeVisible();
await page.screenshot({
path: testInfo.outputPath(`authenticated-${testInfo.project.name}.png`),
mask: [page.locator("main")],
});
});

test("rejects a callback with unrecognized state without exposing provider details", async ({ page }, testInfo) => {
await page.goto("/?code=synthetic-invalid-code&state=synthetic-unrecognized-state#workspace");
await expect(page.getByRole("alert")).toHaveText(
"Sign-in could not be completed. Start again to return to your work.",
);
await expect(page.getByRole("button", { name: "Start sign-in again" })).toBeVisible();
await expect(page.locator("body")).not.toContainText(/invalid_grant|correlation|state mismatch/i);
expect(
await page.evaluate<boolean>(
"document.documentElement.scrollWidth <= document.documentElement.clientWidth",
),
).toBe(true);
await page.screenshot({
path: testInfo.outputPath(`rejected-callback-${testInfo.project.name}.png`),
fullPage: true,
});
});
28 changes: 26 additions & 2 deletions frontend/e2e/support/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,36 @@ const DEMO_PASSWORD = "lineageweave-demo-only";
* Next action: call this once per test before interacting with any
* authenticated destination.
*/
export async function loginAsDemoAnalyst(page: Page): Promise<void> {
await page.goto("/");
export async function loginAsDemoAnalyst(
page: Page,
returnPath = "/",
): Promise<void> {
const tokenRequest = page.waitForRequest((request) =>
request.url().includes("/protocol/openid-connect/token") && request.method() === "POST"
);
await page.goto(returnPath);
await page.getByRole("button", { name: "Log in" }).click();
await page.waitForURL(/\/realms\/lineageweave-demo\/protocol\/openid-connect\/auth/);
const authorizationUrl = new URL(page.url());
if (authorizationUrl.searchParams.get("response_type") !== "code") {
throw new Error("browser sign-in did not request an authorization code");
}
if (authorizationUrl.searchParams.get("code_challenge_method") !== "S256") {
throw new Error("browser sign-in did not request PKCE S256");
}
if (!authorizationUrl.searchParams.get("code_challenge")) {
throw new Error("browser sign-in omitted the PKCE challenge");
}
await page.getByLabel("Username or email").fill(DEMO_USERNAME);
await page.getByLabel("Password", { exact: true }).fill(DEMO_PASSWORD);
await page.getByRole("button", { name: "Sign In" }).click();
await page.waitForURL((url) => !url.pathname.includes("/realms/"));
const exchange = await tokenRequest;
const form = new URLSearchParams(exchange.postData() ?? "");
if (form.get("grant_type") !== "authorization_code" || !form.get("code_verifier")) {
throw new Error("browser sign-in did not exchange an authorization code with PKCE");
}
if (form.has("username") || form.has("password")) {
throw new Error("browser sign-in attempted a password-token exchange");
}
}
5 changes: 5 additions & 0 deletions frontend/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,10 @@ export default defineConfig({
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
{
name: "mobile-chromium",
testMatch: /smoke\.spec\.ts/,
use: { ...devices["Pixel 7"] },
},
],
});
1 change: 1 addition & 0 deletions frontend/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
}

.login-card {
box-sizing: border-box;
width: 100%;
max-width: 420px;
padding: 2.5rem 2rem;
Expand Down
19 changes: 19 additions & 0 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,25 @@ describe("App, unauthenticated", () => {
render(<App showLabPanels />);
expect(screen.getByRole("status")).toHaveTextContent("Loading authentication state...");
});

it("keeps provider errors private and offers a safe sign-in restart", async () => {
window.history.replaceState({}, "", "/?code=private-code&state=invalid#evidence");
mockAuth = {
...mockAuth,
error: new Error("invalid_grant: provider correlation details"),
};
render(<App showLabPanels />);

expect(screen.getByRole("alert")).toHaveTextContent(
"Sign-in could not be completed. Start again to return to your work.",
);
expect(screen.queryByText(/invalid_grant|provider correlation/i)).toBeNull();

await userEvent.click(screen.getByRole("button", { name: "Start sign-in again" }));
expect(signinRedirect).toHaveBeenCalledWith({
state: { returnUrl: "/#evidence" },
});
});
});

function jsonResponse(body: unknown): Response {
Expand Down
14 changes: 13 additions & 1 deletion frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { focusedGraphMustReset } from "./focusedGraphSelection";
import { canAuthorVoice, postPrimaryVoiceLabel } from "./voicePerspective";
import { SignInRecovery } from "./components/SignInRecovery";

import { Component, lazy, Suspense, useCallback, useEffect, useEffectEvent, useRef, useState, type ReactNode } from "react";
import { useAuth } from "react-oidc-context";
Expand Down Expand Up @@ -5302,7 +5303,18 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean
}

if (auth.error) {
return <p className="error">{t(auth.error.message)}</p>;
return (
<SignInRecovery
brandName={brandName}
message={t("Sign-in could not be completed. Start again to return to your work.")}
actionLabel={t("Start sign-in again")}
onRetry={() => {
const returnUrl = returnUrlFromLocation();
rememberOidcReturnUrl(returnUrl);
void auth.signinRedirect({ state: { returnUrl } });
}}
/>
);
}

if (!auth.isAuthenticated) {
Expand Down
24 changes: 24 additions & 0 deletions frontend/src/components/SignInRecovery.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
import { SignInRecovery } from "./SignInRecovery";

const meta = {
title: "Chrome/Sign-in recovery",
component: SignInRecovery,
args: {
brandName: "LineageWeave",
message: "Sign-in could not be completed. Start again to return to your work.",
actionLabel: "Start sign-in again",
onRetry: fn(),
},
parameters: { layout: "fullscreen" },
} satisfies Meta<typeof SignInRecovery>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Desktop: Story = {};

export const NarrowViewport: Story = {
parameters: { viewport: { defaultViewport: "mobile1" } },
};
29 changes: 29 additions & 0 deletions frontend/src/components/SignInRecovery.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/** Offer a safe next action after the browser sign-in callback fails. */
export function SignInRecovery({
brandName,
message,
actionLabel,
onRetry,
}: {
brandName: string;
message: string;
actionLabel: string;
onRetry: () => void;
}) {
return (
<div className="app-shell">
<main className="login-screen">
<div className="login-card">
<div className="login-header">
<h1>{brandName}</h1>
<p className="login-subtitle">Marketing & Operational Lineage Intelligence</p>
</div>
<div className="login-controls">
<p className="error" role="alert">{message}</p>
<button className="btn-primary" onClick={onRetry}>{actionLabel}</button>
</div>
</div>
</main>
</div>
);
}
8 changes: 8 additions & 0 deletions frontend/src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ const TRANSLATIONS: Partial<Record<Locale, Record<string, string>>> = {
"Loading authentication state...": "인증 상태를 불러오는 중...",
"Authenticated, but no access token was returned.": "인증되었지만 액세스 토큰이 반환되지 않았습니다.",
"Log in": "로그인",
"Sign-in could not be completed. Start again to return to your work.": "로그인을 완료하지 못했습니다. 다시 시작하면 작업 화면으로 돌아갑니다.",
"Start sign-in again": "로그인 다시 시작",
"Log out": "로그아웃",
Calendar: "캘린더",
Rankings: "순위",
Expand Down Expand Up @@ -639,6 +641,8 @@ const TRANSLATIONS: Partial<Record<Locale, Record<string, string>>> = {
"Loading authentication state...": "正在加载身份验证状态...",
"Authenticated, but no access token was returned.": "已完成身份验证,但未返回访问令牌。",
"Log in": "登录",
"Sign-in could not be completed. Start again to return to your work.": "无法完成登录。请重新开始以返回工作页面。",
"Start sign-in again": "重新登录",
"Log out": "退出登录",
Calendar: "日历",
Rankings: "排名",
Expand Down Expand Up @@ -1243,6 +1247,8 @@ const TRANSLATIONS: Partial<Record<Locale, Record<string, string>>> = {
"Loading authentication state...": "認証状態を読み込んでいます...",
"Authenticated, but no access token was returned.": "認証済みですが、アクセストークンが返されませんでした。",
"Log in": "ログイン",
"Sign-in could not be completed. Start again to return to your work.": "サインインを完了できませんでした。もう一度開始すると作業画面に戻ります。",
"Start sign-in again": "サインインをやり直す",
"Log out": "ログアウト",
Calendar: "カレンダー",
Rankings: "ランキング",
Expand Down Expand Up @@ -1827,6 +1833,8 @@ const TRANSLATIONS: Partial<Record<Locale, Record<string, string>>> = {
"Loading authentication state...": "Đang tải trạng thái xác thực...",
"Authenticated, but no access token was returned.": "Đã xác thực nhưng không nhận được mã thông báo truy cập.",
"Log in": "Đăng nhập",
"Sign-in could not be completed. Start again to return to your work.": "Không thể hoàn tất đăng nhập. Hãy bắt đầu lại để trở về công việc của bạn.",
"Start sign-in again": "Đăng nhập lại",
"Log out": "Đăng xuất",
Calendar: "Lịch",
Rankings: "Xếp hạng",
Expand Down
Loading