fix: preserve integration provider auth errors (#2119)
This commit is contained in:
@@ -38,6 +38,7 @@ func corsMiddleware() gin.HandlerFunc {
|
||||
}
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PATCH, PUT, DELETE, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Authorization, X-Kandev-Interim-Settings-Interlock, Upgrade, Connection, Sec-WebSocket-Key, Sec-WebSocket-Version, Sec-WebSocket-Protocol")
|
||||
c.Header("Access-Control-Expose-Headers", "WWW-Authenticate")
|
||||
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
|
||||
@@ -31,6 +31,32 @@ func TestCORSMiddlewareEchoesOriginForCredentialedRequests(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORSMiddlewareExposesAuthenticationChallengeHeader(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.Use(corsMiddleware())
|
||||
router.GET("/protected", func(c *gin.Context) {
|
||||
c.Header("WWW-Authenticate", "Bearer")
|
||||
c.Status(http.StatusUnauthorized)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
||||
req.Host = "localhost:38429"
|
||||
req.Header.Set("Origin", "http://localhost:37429")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
|
||||
}
|
||||
if got := rec.Header().Get("WWW-Authenticate"); got != "Bearer" {
|
||||
t.Fatalf("WWW-Authenticate = %q, want Bearer", got)
|
||||
}
|
||||
if got := rec.Header().Get("Access-Control-Expose-Headers"); !strings.Contains(got, "WWW-Authenticate") {
|
||||
t.Fatalf("Access-Control-Expose-Headers = %q, want WWW-Authenticate", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORSMiddlewareAllowsInterimSettingsInterlockHeader(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
|
||||
@@ -17,6 +17,7 @@ const changeConnectionLabel = "Change connection";
|
||||
const registrationDisplayName = "Work automation";
|
||||
const githubAppLabel = "GitHub App";
|
||||
const saveChangesLabel = "Save changes";
|
||||
const personalAccessTokenLabel = "Personal access token";
|
||||
const WORKSPACE_ID = "workspace-1";
|
||||
const DESKTOP_CONNECTION_TEST_ID = "github-connection-desktop";
|
||||
const taskAccess: TaskGitCredentialsState = {
|
||||
@@ -155,7 +156,7 @@ describe("GitHubConnectionDialog task access", () => {
|
||||
.getByTestId("github-task-access-option-executor")
|
||||
.querySelector('[role="radio"]')!;
|
||||
fireEvent.click(executor);
|
||||
fireEvent.change(screen.getByLabelText("Personal access token"), {
|
||||
fireEvent.change(screen.getByLabelText(personalAccessTokenLabel), {
|
||||
target: { value: "ghp_replacement" },
|
||||
});
|
||||
|
||||
@@ -196,7 +197,7 @@ describe("GitHubConnectionDialog task access", () => {
|
||||
mocks.setConnection.mockRejectedValue(new Error("Token rejected"));
|
||||
render(<PartialSaveHarness />);
|
||||
fireEvent.click(screen.getByRole("button", { name: changeConnectionLabel }));
|
||||
const token = screen.getByLabelText("Personal access token") as HTMLInputElement;
|
||||
const token = screen.getByLabelText(personalAccessTokenLabel) as HTMLInputElement;
|
||||
fireEvent.change(token, { target: { value: "ghp_retry_me" } });
|
||||
fireEvent.click(
|
||||
screen.getByTestId("github-task-access-option-executor").querySelector('[role="radio"]')!,
|
||||
@@ -207,6 +208,32 @@ describe("GitHubConnectionDialog task access", () => {
|
||||
await screen.findByText(/Some settings were saved/);
|
||||
expect(token.value).toBe("ghp_retry_me");
|
||||
});
|
||||
|
||||
it("keeps an invalid PAT draft in the open connection dialog", async () => {
|
||||
const onSaved = vi.fn();
|
||||
mocks.setConnection.mockRejectedValue(new Error("GitHub token is invalid"));
|
||||
render(
|
||||
<ToastProvider>
|
||||
<GitHubConnectionDialog
|
||||
status={status}
|
||||
workspaceId={WORKSPACE_ID}
|
||||
onSaved={onSaved}
|
||||
taskAccess={taskAccess}
|
||||
/>
|
||||
</ToastProvider>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: changeConnectionLabel }));
|
||||
const dialog = await screen.findByTestId(DESKTOP_CONNECTION_TEST_ID);
|
||||
const token = screen.getByLabelText(personalAccessTokenLabel) as HTMLInputElement;
|
||||
fireEvent.change(token, { target: { value: "ghp_invalid" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: saveChangesLabel }));
|
||||
|
||||
await screen.findByText("GitHub token is invalid");
|
||||
expect(token.value).toBe("ghp_invalid");
|
||||
expect(dialog.getAttribute("data-state")).toBe("open");
|
||||
expect(onSaved).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
afterEach(() => cleanup());
|
||||
|
||||
@@ -216,7 +243,7 @@ describe("GitHubConnectionDialog", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: changeConnectionLabel }));
|
||||
expect(await screen.findByTestId(DESKTOP_CONNECTION_TEST_ID)).toBeTruthy();
|
||||
expect(screen.getByLabelText("Connection method")).toBeTruthy();
|
||||
expect(screen.getAllByText("Personal access token")).not.toHaveLength(0);
|
||||
expect(screen.getAllByText(personalAccessTokenLabel)).not.toHaveLength(0);
|
||||
expect(screen.getByText("GitHub CLI account")).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByText(githubAppLabel, { selector: "span" }));
|
||||
|
||||
@@ -50,6 +50,25 @@ test.describe.serial("opt-in authentication", () => {
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test("exposes a session challenge to split-origin browsers", async ({ browser, backend }) => {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
await page.goto(`http://127.0.0.1:${backend.port}/login`);
|
||||
|
||||
const challenge = await page.evaluate(async (apiBaseUrl) => {
|
||||
const response = await fetch(`${apiBaseUrl}/api/v1/workspaces`, {
|
||||
credentials: "include",
|
||||
});
|
||||
return {
|
||||
status: response.status,
|
||||
challenge: response.headers.get("WWW-Authenticate"),
|
||||
};
|
||||
}, backend.baseUrl);
|
||||
|
||||
expect(challenge).toEqual({ status: 401, challenge: "Bearer" });
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test("setup wizard promotes the first admin and signs them in", async ({ browser, backend }) => {
|
||||
const context = await browser.newContext({ baseURL: backend.frontendUrl });
|
||||
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { expect, type Route } from "@playwright/test";
|
||||
import { test } from "../../fixtures/test-base";
|
||||
import { GitHubAuthSettingsPage } from "../../pages/github-auth-settings-page";
|
||||
|
||||
async function fulfillProviderUnauthorized(route: Route, error: string) {
|
||||
await route.fulfill({
|
||||
status: 401,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ error }),
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("integration provider authentication errors", () => {
|
||||
test("keeps GitHub on the page and shows the search error", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
await apiClient.mockGitHubReset();
|
||||
await apiClient.mockGitHubSetAppRegistration({
|
||||
id: "provider-auth-errors-app",
|
||||
display_name: "Provider auth errors",
|
||||
app_id: 2026,
|
||||
});
|
||||
await apiClient.mockGitHubSetWorkspaceConnection(seedData.workspaceId, {
|
||||
source: "github_app_installation",
|
||||
status: "active",
|
||||
app_registration_id: "provider-auth-errors-app",
|
||||
installation_id: 2026,
|
||||
installation_account_login: "workspace-github",
|
||||
installation_account_type: "Organization",
|
||||
});
|
||||
await apiClient.mockGitHubSetPersonalConnection(seedData.workspaceId, {
|
||||
login: "personal-github",
|
||||
status: "active",
|
||||
github_user_id: 101,
|
||||
access_expires_at: "2030-01-01T00:00:00Z",
|
||||
});
|
||||
await testPage.route("**/api/v1/github/user/prs**", (route) =>
|
||||
fulfillProviderUnauthorized(route, "GitHub credentials expired"),
|
||||
);
|
||||
|
||||
await testPage.goto("/github");
|
||||
|
||||
await expect(testPage).toHaveURL(/\/github$/);
|
||||
await expect(testPage.getByText("GitHub credentials expired", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("keeps GitLab on the page and shows the search error", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
await apiClient.configureGitLab(seedData.workspaceId);
|
||||
await apiClient.mockGitLabSetUser(seedData.workspaceId, "gitlab-user");
|
||||
await testPage.route("**/api/v1/gitlab/user/mrs**", (route) =>
|
||||
fulfillProviderUnauthorized(route, "GitLab credentials expired"),
|
||||
);
|
||||
|
||||
await testPage.goto("/gitlab");
|
||||
|
||||
await expect(testPage).toHaveURL(/\/gitlab$/);
|
||||
await expect(testPage.getByText("GitLab credentials expired", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("keeps Jira on the page and shows the authentication error", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
await apiClient.setJiraConfig({
|
||||
siteUrl: "https://acme.atlassian.net",
|
||||
email: "alice@example.com",
|
||||
secret: "jira-api-token",
|
||||
workspaceId: seedData.workspaceId,
|
||||
});
|
||||
await apiClient.waitForIntegrationAuthHealthy("jira", { workspaceId: seedData.workspaceId });
|
||||
await testPage.route("**/api/v1/jira/tickets**", (route) =>
|
||||
fulfillProviderUnauthorized(route, "jira api: status 401: credentials expired"),
|
||||
);
|
||||
|
||||
await testPage.goto("/jira");
|
||||
|
||||
await expect(testPage).toHaveURL(/\/jira$/);
|
||||
await expect(
|
||||
testPage.getByRole("heading", { name: "Jira authentication required" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("keeps Linear on the page and shows the authentication error", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
await apiClient.setLinearConfig({
|
||||
secret: "lin_api_expired",
|
||||
workspaceId: seedData.workspaceId,
|
||||
});
|
||||
await apiClient.waitForIntegrationAuthHealthy("linear", {
|
||||
workspaceId: seedData.workspaceId,
|
||||
});
|
||||
await testPage.route("**/api/v1/linear/issues**", (route) =>
|
||||
fulfillProviderUnauthorized(route, "linear api: status 401: credentials expired"),
|
||||
);
|
||||
|
||||
await testPage.goto("/linear");
|
||||
|
||||
await expect(testPage).toHaveURL(/\/linear$/);
|
||||
await expect(
|
||||
testPage.getByRole("heading", { name: "Linear authentication required" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("keeps an invalid GitHub PAT draft visible and the connection active", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
await apiClient.mockGitHubReset();
|
||||
await apiClient.mockGitHubSetWorkspaceConnection(seedData.workspaceId, {
|
||||
source: "pat",
|
||||
status: "active",
|
||||
login: "existing-github",
|
||||
});
|
||||
await testPage.route("**/api/v1/github/workspace-connection?*", async (route) => {
|
||||
if (route.request().method() === "PUT") {
|
||||
await route.fulfill({
|
||||
status: 400,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ error: "GitHub token is invalid" }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
const settings = new GitHubAuthSettingsPage(testPage);
|
||||
await settings.goto(seedData.workspaceId);
|
||||
await expect(
|
||||
testPage.getByTestId("github-workspace-automation").getByText("existing-github", {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
const surface = await settings.openConnection();
|
||||
const token = surface.getByRole("textbox", { name: "Personal access token" });
|
||||
await token.fill("ghp_invalid");
|
||||
await surface.getByRole("button", { name: "Save changes" }).click();
|
||||
|
||||
await expect(testPage).toHaveURL(
|
||||
new RegExp(`/settings/workspace/${seedData.workspaceId}/integrations/github$`),
|
||||
);
|
||||
await expect(testPage.getByText("GitHub token is invalid", { exact: true })).toBeVisible();
|
||||
await expect(token).toHaveValue("ghp_invalid");
|
||||
await expect(surface).toHaveAttribute("data-state", "open");
|
||||
await expect(
|
||||
testPage.getByTestId("github-workspace-automation").getByText("existing-github", {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,11 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { fetchJson } from "./client";
|
||||
import { ApiError, fetchJson, setOnUnauthorized } from "./client";
|
||||
|
||||
const interlockToken = "replayable-per-boot-value";
|
||||
|
||||
describe("fetchJson", () => {
|
||||
afterEach(() => {
|
||||
setOnUnauthorized(null);
|
||||
vi.unstubAllGlobals();
|
||||
delete (window as unknown as { __KANDEV_BOOT_PAYLOAD__?: unknown }).__KANDEV_BOOT_PAYLOAD__;
|
||||
});
|
||||
@@ -62,4 +63,48 @@ describe("fetchJson", () => {
|
||||
interlock: interlockToken,
|
||||
});
|
||||
});
|
||||
|
||||
it("notifies the app for a Kandev session challenge", async () => {
|
||||
const unauthorized = vi.fn();
|
||||
setOnUnauthorized(unauthorized);
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ error: "authentication required" }), {
|
||||
status: 401,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"WWW-Authenticate": "Bearer",
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
fetchJson("/api/v1/workspaces", { baseUrl: "http://kandev.test" }),
|
||||
).rejects.toBeInstanceOf(ApiError);
|
||||
expect(unauthorized).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps an unchallenged provider 401 in the calling integration", async () => {
|
||||
const unauthorized = vi.fn();
|
||||
setOnUnauthorized(unauthorized);
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ error: "GitHub credentials are invalid" }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const request = fetchJson("/api/v1/github/user/prs", { baseUrl: "http://kandev.test" });
|
||||
await expect(request).rejects.toMatchObject({
|
||||
name: "ApiError",
|
||||
status: 401,
|
||||
message: "GitHub credentials are invalid",
|
||||
});
|
||||
expect(unauthorized).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,16 +32,21 @@ function resolveUrl(pathOrUrl: string, baseUrl: string) {
|
||||
return `${baseUrl}${pathOrUrl}`;
|
||||
}
|
||||
|
||||
// Notified whenever a request comes back 401. The auth gate (src/main.tsx)
|
||||
// registers a callback that clears the store's auth slice and redirects to
|
||||
// the login page, so a session that expires mid-use doesn't leave the SPA
|
||||
// stuck making requests against a stale authenticated identity.
|
||||
// Notified when a request carries a Kandev session challenge. The auth gate
|
||||
// (src/main.tsx) registers a callback that clears the store's auth slice and
|
||||
// redirects to the login page, so a session that expires mid-use doesn't leave
|
||||
// the SPA stuck making requests against a stale authenticated identity.
|
||||
let onUnauthorized: (() => void) | null = null;
|
||||
|
||||
export function setOnUnauthorized(cb: (() => void) | null): void {
|
||||
onUnauthorized = cb;
|
||||
}
|
||||
|
||||
function isKandevAuthChallenge(response: Response): boolean {
|
||||
const challenge = response.headers.get("WWW-Authenticate");
|
||||
return challenge?.split(",").some((value) => /^Bearer(?:\s|$)/i.test(value.trim())) ?? false;
|
||||
}
|
||||
|
||||
async function throwFromResponse(response: Response): Promise<never> {
|
||||
let body: unknown = null;
|
||||
let message = `Request failed: ${response.status} ${response.statusText}`;
|
||||
@@ -68,7 +73,7 @@ export async function fetchJson<T>(pathOrUrl: string, options?: ApiRequestOption
|
||||
headers: buildRequestHeaders(options),
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) onUnauthorized?.();
|
||||
if (response.status === 401 && isKandevAuthChallenge(response)) onUnauthorized?.();
|
||||
await throwFromResponse(response);
|
||||
}
|
||||
if (response.status === 204) return undefined as T;
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
---
|
||||
spec: docs/specs/auth/spec.md
|
||||
related_spec: docs/specs/integrations/github-authentication.md
|
||||
created: 2026-07-31
|
||||
status: done
|
||||
---
|
||||
|
||||
# Implementation Plan: Integration Provider Authentication Errors
|
||||
|
||||
## Overview
|
||||
|
||||
The shared web API client currently treats every HTTP 401 as an expired Kandev browser session.
|
||||
GitHub, GitLab, Jira, and Linear also return 401 when their own credentials expire, so the global
|
||||
handler navigates to `/login` before each integration page can render its existing loading error.
|
||||
Gate the global redirect on the backend's Kandev session challenge, then prove provider errors stay
|
||||
local and invalid GitHub PAT saves remain visible and retryable.
|
||||
|
||||
## Confirmed root cause
|
||||
|
||||
- `apps/web/lib/api/client.ts` calls the global `onUnauthorized` callback for every 401 response.
|
||||
- `apps/web/src/main.tsx` implements that callback with `window.location.assign("/login")`.
|
||||
- Kandev's global auth middleware identifies a real session challenge with
|
||||
`WWW-Authenticate: Bearer`; upstream-provider 401 responses from integration handlers do not.
|
||||
- The four provider data pages (`/github`, `/gitlab`, `/jira`, `/linear`) already catch request
|
||||
failures and render result errors, but the unconditional global redirect prevents users from
|
||||
seeing them.
|
||||
- GitHub PAT replacement is already validated before persistence and the connection dialog already
|
||||
retains failed drafts; focused coverage is missing for a connection-only invalid PAT rejection.
|
||||
|
||||
## Frontend
|
||||
|
||||
### Shared API authentication classification
|
||||
|
||||
- Update `apps/web/lib/api/client.ts` so `onUnauthorized` runs only for a 401 carrying Kandev's
|
||||
`WWW-Authenticate: Bearer` session challenge.
|
||||
- Continue parsing and throwing `ApiError` for every non-2xx response so provider pages and settings
|
||||
forms receive the original sanitized error message.
|
||||
- Add `apps/web/lib/api/client.test.ts` covering challenged and unchallenged 401 responses.
|
||||
|
||||
### GitHub PAT save feedback
|
||||
|
||||
- Extend `apps/web/components/github/github-connection-dialog.test.tsx` with a connection-only
|
||||
invalid-PAT rejection. Assert the dialog stays open, the error toast is visible, the token draft
|
||||
is retained, and `onSaved` is not called.
|
||||
- No production component change is expected unless the regression exposes behavior that differs
|
||||
from the amended spec.
|
||||
|
||||
### Mobile contract
|
||||
|
||||
The repair changes request classification only. It does not change composition, navigation,
|
||||
overlays, touch behavior, scrolling, or viewport-dependent interaction. Desktop and mobile share
|
||||
the same API client and provider-error state, so focused unit/component coverage plus the existing
|
||||
mobile integration settings suites satisfy mobile parity; no new mobile-only composition is needed.
|
||||
|
||||
## Tests
|
||||
|
||||
- **What:** a Kandev session challenge still clears the stale browser identity.
|
||||
**File:** `apps/web/lib/api/client.test.ts`.
|
||||
**How:** mock a 401 response with `WWW-Authenticate: Bearer` and assert `onUnauthorized` runs once
|
||||
before `ApiError` is thrown.
|
||||
- **What:** an integration/provider 401 remains a normal request error.
|
||||
**File:** `apps/web/lib/api/client.test.ts`.
|
||||
**How:** mock a 401 without the challenge header, assert `onUnauthorized` is not called, and assert
|
||||
the provider error body becomes the thrown `ApiError` message.
|
||||
- **What:** an invalid GitHub PAT is visible and retryable.
|
||||
**File:** `apps/web/components/github/github-connection-dialog.test.tsx`.
|
||||
**How:** reject `setGitHubWorkspaceConnection`, save a PAT-only draft, and assert the open dialog,
|
||||
retained input, error toast, and unchanged saved callback.
|
||||
|
||||
## E2E Tests
|
||||
|
||||
- **Scenario:** GIVEN configured GitHub, GitLab, Jira, and Linear integrations, WHEN each provider
|
||||
data request returns an unchallenged 401, THEN its page remains on the provider route and displays
|
||||
the loading error instead of navigating to `/login`.
|
||||
**File:** `apps/web/e2e/tests/integrations/provider-auth-errors.spec.ts`.
|
||||
**What to verify:** seed each integration with existing E2E helpers, intercept its primary data
|
||||
endpoint with a sanitized 401 response, assert the provider-specific error text and unchanged URL.
|
||||
- **Scenario:** GIVEN GitHub settings with a connected workspace, WHEN a replacement PAT save is
|
||||
rejected as invalid, THEN the connection surface remains open, displays the error, and retains the
|
||||
submitted token for correction.
|
||||
**File:** `apps/web/e2e/tests/integrations/provider-auth-errors.spec.ts`.
|
||||
**What to verify:** intercept `PUT /api/v1/github/workspace-connection`, submit the dialog, and
|
||||
assert no `/login` navigation, visible error feedback, retained PAT input, and existing connection.
|
||||
|
||||
## Implementation Tasks
|
||||
|
||||
- [x] [Task 01: Classify session challenges and preserve PAT errors](task-01-classify-session-challenges.md)
|
||||
- [x] [Task 02: Prove provider page behavior end to end](task-02-provider-auth-e2e.md)
|
||||
|
||||
Execution is sequential: Task 02 depends on Task 01. No task is parallel-safe because the E2E proof
|
||||
depends on the shared API-client behavior implemented by Task 01.
|
||||
|
||||
## Risks And Out Of Scope
|
||||
|
||||
- The challenge check must preserve redirect behavior for truly expired Kandev sessions.
|
||||
- Provider error bodies remain subject to each backend handler's existing sanitization; this repair
|
||||
does not redesign provider-specific copy or HTTP status mappings.
|
||||
- No credential schema, persistence, health-poller, settings layout, or mobile composition changes.
|
||||
- No new behavior for integration settings beyond GitHub's requested invalid-PAT save feedback.
|
||||
@@ -0,0 +1,80 @@
|
||||
---
|
||||
id: "01-classify-session-challenges"
|
||||
title: "Classify session challenges and preserve PAT errors"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/auth/spec.md"
|
||||
related_spec: "../../specs/integrations/github-authentication.md"
|
||||
---
|
||||
|
||||
# Task 01: Classify session challenges and preserve PAT errors
|
||||
|
||||
## Intent
|
||||
|
||||
Prevent third-party provider 401 responses from invoking the global Kandev login redirect while
|
||||
preserving the redirect for a real Kandev session challenge. Prove that an invalid GitHub PAT save
|
||||
remains visible and retryable in the existing connection dialog.
|
||||
|
||||
## Acceptance
|
||||
|
||||
1. `fetchJson` invokes `onUnauthorized` only for a 401 carrying Kandev's
|
||||
`WWW-Authenticate: Bearer` challenge and still throws `ApiError` for both challenged and
|
||||
unchallenged failures.
|
||||
2. An unchallenged provider 401 preserves its parsed error message for the calling integration UI.
|
||||
3. A PAT-only GitHub connection save failure leaves the dialog open, retains the submitted token,
|
||||
displays the error, and does not call the saved callback.
|
||||
|
||||
## TDD
|
||||
|
||||
1. Add the challenged/unchallenged 401 unit cases and the PAT-only dialog rejection case.
|
||||
2. Run them against current code and confirm the unchallenged-401 case fails because
|
||||
`onUnauthorized` is called.
|
||||
3. Implement the smallest shared-client classification change.
|
||||
4. Rerun the focused tests and refactor only if needed.
|
||||
|
||||
## Files Likely Touched
|
||||
|
||||
- `apps/web/lib/api/client.ts`
|
||||
- `apps/web/lib/api/client.test.ts`
|
||||
- `apps/web/components/github/github-connection-dialog.test.tsx`
|
||||
|
||||
## Dependencies
|
||||
|
||||
None.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential` — Task 02 depends on this behavior.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps && pnpm --filter @kandev/web test -- lib/api/client.test.ts components/github/github-connection-dialog.test.tsx
|
||||
```
|
||||
|
||||
## Inputs
|
||||
|
||||
- `docs/specs/auth/spec.md` — session challenge versus provider failure behavior.
|
||||
- `docs/specs/integrations/github-authentication.md` — invalid PAT save behavior.
|
||||
- `apps/backend/internal/auth/httpmw/middleware.go` — authoritative Kandev session challenge.
|
||||
- `apps/web/src/main.tsx` — global unauthorized callback.
|
||||
|
||||
## Output Contract
|
||||
|
||||
Verification record:
|
||||
|
||||
- RED: before the challenge gate, the focused provider-401 test failed because
|
||||
`onUnauthorized` was invoked for an unchallenged 401.
|
||||
- Changed files: `apps/web/lib/api/client.ts`,
|
||||
`apps/web/lib/api/client.test.ts`, and
|
||||
`apps/web/components/github/github-connection-dialog.test.tsx`.
|
||||
- Final focused result: 14 tests passed; the managed provider-auth E2E spec passed all 5
|
||||
scenarios; web typecheck and changed-file lint passed.
|
||||
- Follow-up review fix: `apps/backend/internal/backendapp/middleware.go` now exposes
|
||||
`WWW-Authenticate` to split-origin browsers, with a regression test in
|
||||
`apps/backend/internal/backendapp/middleware_test.go`.
|
||||
- Remaining risk: session redirect classification depends on the backend emitting the
|
||||
`WWW-Authenticate: Bearer` challenge; same-origin and split-origin paths now both preserve
|
||||
that signal.
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
id: "02-provider-auth-e2e"
|
||||
title: "Prove provider page behavior end to end"
|
||||
status: done
|
||||
wave: 2
|
||||
depends_on: ["01-classify-session-challenges"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/auth/spec.md"
|
||||
related_spec: "../../specs/integrations/github-authentication.md"
|
||||
---
|
||||
|
||||
# Task 02: Prove provider page behavior end to end
|
||||
|
||||
## Intent
|
||||
|
||||
Exercise the four provider data pages and GitHub PAT settings through the browser so a future
|
||||
regression cannot turn an integration credential failure into a Kandev login redirect.
|
||||
|
||||
## Acceptance
|
||||
|
||||
1. GitHub, GitLab, Jira, and Linear pages each remain on their own route and show a provider loading
|
||||
error after their primary data endpoint returns an unchallenged 401.
|
||||
2. GitHub settings retain an invalid replacement PAT, keep the connection surface open, show the
|
||||
backend error, and keep the existing connection active.
|
||||
3. The focused production-build E2E spec passes without adding mobile-specific UI or changing page
|
||||
composition.
|
||||
|
||||
## TDD
|
||||
|
||||
1. Add the browser scenarios using existing integration configuration helpers and route
|
||||
interception for sanitized provider 401 responses.
|
||||
2. Run the managed spec and correct fixture or selector assumptions exposed by the real browser
|
||||
surface before interpreting a failure as a product regression.
|
||||
3. Rerun after Task 01 and confirm the provider routes and error UI remain visible.
|
||||
|
||||
## Files Likely Touched
|
||||
|
||||
- `apps/web/e2e/tests/integrations/provider-auth-errors.spec.ts`
|
||||
|
||||
## Dependencies
|
||||
|
||||
Task 01 complete.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential` — validates Task 01 through real SPA routing.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps/web && pnpm e2e:run tests/integrations/provider-auth-errors.spec.ts
|
||||
```
|
||||
|
||||
## Inputs
|
||||
|
||||
- `apps/web/e2e/helpers/api-client.ts` — GitHub, GitLab, Jira, and Linear configuration helpers.
|
||||
- `apps/web/e2e/pages/github-auth-settings-page.ts` — GitHub connection dialog selectors.
|
||||
- The existing provider page search hooks and list error renderers under `apps/web/app/` and
|
||||
`apps/web/components/{github,gitlab,jira,linear}/`.
|
||||
|
||||
## Output Contract
|
||||
|
||||
The first managed run exposed two test assumptions (GitHub personal identity requires an
|
||||
App-backed workspace seed, and the PAT input must be selected by its textbox role); it did not
|
||||
reach a product redirect failure. After correcting those assumptions, the final managed runner
|
||||
passed all five scenarios. No failure artifacts remain relevant, and the remaining risk is limited
|
||||
to provider-specific backend error-body wording outside the sanitized responses covered here.
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
status: building
|
||||
created: 2026-07-24
|
||||
amended: 2026-07-31
|
||||
owner: tbd
|
||||
---
|
||||
|
||||
@@ -61,6 +62,11 @@ the local single-user install with a login screen it never asked for.
|
||||
anonymous visitors), `/api/v1/features`, credential endpoints
|
||||
(login/setup/invite-accept), and self-authenticating webhooks (automation
|
||||
`X-Webhook-Secret`, office channel HMAC, plugin webhooks).
|
||||
- **Session challenges are distinct from provider authentication failures.**
|
||||
The browser clears its Kandev identity and opens `/login` only when a 401 is
|
||||
a Kandev session challenge. A third-party integration may also return 401
|
||||
when GitHub, GitLab, Jira, or Linear rejects its own credential; that failure
|
||||
remains on the current page and is rendered by the integration surface.
|
||||
- **Disable.** An admin can turn auth off again (unless env-forced).
|
||||
Ownership data is retained; everyone reaching the instance has full access
|
||||
again.
|
||||
@@ -80,6 +86,19 @@ the local single-user install with a login screen it never asked for.
|
||||
- A server bound to non-loopback interfaces with auth disabled logs a
|
||||
prominent startup warning.
|
||||
- Sessions/PATs of disabled users fail closed immediately.
|
||||
- Third-party provider authentication failures do not clear the authenticated
|
||||
Kandev user, replace the current route, or trigger a login redirect.
|
||||
|
||||
## Scenarios
|
||||
|
||||
- **GIVEN** an authenticated Kandev browser session, **WHEN** a protected API
|
||||
request receives a Kandev session challenge, **THEN** the browser clears the
|
||||
stale Kandev identity and navigates to `/login`.
|
||||
- **GIVEN** an authenticated Kandev browser session and an expired or invalid
|
||||
third-party integration credential, **WHEN** GitHub, GitLab, Jira, or Linear
|
||||
data loading returns 401 without a Kandev session challenge, **THEN** the
|
||||
browser stays on the integration route and displays the provider loading
|
||||
error.
|
||||
|
||||
## Known v1 limits
|
||||
|
||||
|
||||
@@ -429,6 +429,12 @@ post-signature processing failures produce `failing`; a later valid successful d
|
||||
`github_app_registration_in_use` with a non-secret binding count.
|
||||
- Changing workspace auth while a flow is open makes the stale callback fail without reverting the
|
||||
newer connection.
|
||||
- A PAT replacement is validated against GitHub before it replaces the current workspace
|
||||
connection. An invalid PAT leaves the previous connection unchanged, keeps the submitted draft
|
||||
available for correction, and shows the validation error in the connection dialog.
|
||||
- If a previously valid GitHub credential expires or is revoked, My GitHub stays on the current
|
||||
route and renders a reconnect/loading error instead of treating GitHub's 401 as an expired Kandev
|
||||
login session.
|
||||
|
||||
## Persistence Guarantees
|
||||
|
||||
@@ -550,6 +556,12 @@ registration and never creates a global default.
|
||||
- **GIVEN** a PAT or named CLI connection draft and a changed task access mode, **WHEN** the user
|
||||
presses the dialog's single **Save changes** action, **THEN** both drafts are persisted, the dialog
|
||||
closes only after both succeed, and reopening shows the selected account and task mode.
|
||||
- **GIVEN** a user enters an invalid replacement PAT, **WHEN** GitHub rejects it during **Save
|
||||
changes**, **THEN** the dialog remains open, the submitted PAT remains available for correction,
|
||||
an error is shown, and the previously active workspace connection is unchanged.
|
||||
- **GIVEN** a configured PAT has expired or been revoked, **WHEN** the user opens My GitHub and the
|
||||
provider data request returns 401, **THEN** the page remains on `/github`, shows an authentication
|
||||
loading error, and does not navigate to the Kandev login screen.
|
||||
- **GIVEN** a changed task access mode but no PAT or CLI connection change, **WHEN** the user presses
|
||||
**Save changes**, **THEN** only the task policy is persisted and the selected automation identity
|
||||
remains unchanged.
|
||||
|
||||
Reference in New Issue
Block a user