fix: preserve integration provider auth errors (#2119)

This commit is contained in:
Carlos Florêncio
2026-08-01 11:19:40 +01:00
committed by GitHub
parent e466bda55f
commit bb525f1894
12 changed files with 570 additions and 9 deletions
@@ -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();
});
});
+46 -1
View File
@@ -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();
});
});
+10 -5
View File
@@ -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.
+19
View File
@@ -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.