fix: return 404 instead of 401 for missing OAuth2 apps (#18755)

## Problem

Users were being automatically logged out when deleting OAuth2
applications.

## Root Cause

1. User deletes OAuth2 app successfully
2. React Query automatically refetches the app data  
3. Management API incorrectly returned **401 Unauthorized** for the
missing app
4. Frontend axios interceptor sees 401 and calls `signOut()`
5. User gets logged out unexpectedly

## Solution

- Change management API to return **404 Not Found** for missing OAuth2
apps
- OAuth2 protocol endpoints continue returning 401 per RFC 6749
- Rename `writeInvalidClient` to `writeClientNotFound` for clarity

## Additional Changes

- Add conditional OAuth2 navigation when experiment is enabled or in dev
builds
- Add `isDevBuild()` utility and `buildInfo` to dashboard context
- Minor improvements to format script and warning dialogs

Signed-off-by: Thomas Kosiewski <tk@coder.com>
This commit is contained in:
Thomas Kosiewski
2025-07-07 19:57:32 +02:00
committed by GitHub
parent f2983164f5
commit 3dcd2acf1d
11 changed files with 174 additions and 47 deletions
@@ -1,9 +1,11 @@
import { appearance } from "api/queries/appearance";
import { buildInfo } from "api/queries/buildInfo";
import { entitlements } from "api/queries/entitlements";
import { experiments } from "api/queries/experiments";
import { organizations } from "api/queries/organizations";
import type {
AppearanceConfig,
BuildInfoResponse,
Entitlements,
Experiment,
Organization,
@@ -21,6 +23,7 @@ export interface DashboardValue {
entitlements: Entitlements;
experiments: Experiment[];
appearance: AppearanceConfig;
buildInfo: BuildInfoResponse;
organizations: readonly Organization[];
showOrganizations: boolean;
canViewOrganizationSettings: boolean;
@@ -36,12 +39,14 @@ export const DashboardProvider: FC<PropsWithChildren> = ({ children }) => {
const entitlementsQuery = useQuery(entitlements(metadata.entitlements));
const experimentsQuery = useQuery(experiments(metadata.experiments));
const appearanceQuery = useQuery(appearance(metadata.appearance));
const buildInfoQuery = useQuery(buildInfo(metadata["build-info"]));
const organizationsQuery = useQuery(organizations());
const error =
entitlementsQuery.error ||
appearanceQuery.error ||
experimentsQuery.error ||
buildInfoQuery.error ||
organizationsQuery.error;
if (error) {
@@ -52,6 +57,7 @@ export const DashboardProvider: FC<PropsWithChildren> = ({ children }) => {
!entitlementsQuery.data ||
!appearanceQuery.data ||
!experimentsQuery.data ||
!buildInfoQuery.data ||
!organizationsQuery.data;
if (isLoading) {
@@ -70,6 +76,7 @@ export const DashboardProvider: FC<PropsWithChildren> = ({ children }) => {
entitlements: entitlementsQuery.data,
experiments: experimentsQuery.data,
appearance: appearanceQuery.data,
buildInfo: buildInfoQuery.data,
organizations: organizationsQuery.data,
showOrganizations,
canViewOrganizationSettings:
@@ -8,7 +8,8 @@ import { DeploymentSidebarView } from "./DeploymentSidebarView";
*/
export const DeploymentSidebar: FC = () => {
const { permissions } = useAuthenticated();
const { entitlements, showOrganizations } = useDashboard();
const { entitlements, showOrganizations, experiments, buildInfo } =
useDashboard();
const hasPremiumLicense =
entitlements.features.multiple_organizations.enabled;
@@ -17,6 +18,8 @@ export const DeploymentSidebar: FC = () => {
permissions={permissions}
showOrganizations={showOrganizations}
hasPremiumLicense={hasPremiumLicense}
experiments={experiments}
buildInfo={buildInfo}
/>
);
};
@@ -1,3 +1,4 @@
import type { BuildInfoResponse, Experiment } from "api/typesGenerated";
import {
Sidebar as BaseSidebar,
SettingsSidebarNavItem as SidebarNavItem,
@@ -6,12 +7,15 @@ import { Stack } from "components/Stack/Stack";
import { ArrowUpRight } from "lucide-react";
import type { Permissions } from "modules/permissions";
import type { FC } from "react";
import { isDevBuild } from "utils/buildInfo";
interface DeploymentSidebarViewProps {
/** Site-wide permissions. */
permissions: Permissions;
showOrganizations: boolean;
hasPremiumLicense: boolean;
experiments: Experiment[];
buildInfo: BuildInfoResponse;
}
/**
@@ -22,6 +26,8 @@ export const DeploymentSidebarView: FC<DeploymentSidebarViewProps> = ({
permissions,
showOrganizations,
hasPremiumLicense,
experiments,
buildInfo,
}) => {
return (
<BaseSidebar>
@@ -47,10 +53,12 @@ export const DeploymentSidebarView: FC<DeploymentSidebarViewProps> = ({
External Authentication
</SidebarNavItem>
)}
{/* Not exposing this yet since token exchange is not finished yet.
<SidebarNavItem href="oauth2-provider/apps">
OAuth2 Applications
</SidebarNavItem>*/}
{permissions.viewDeploymentConfig &&
(experiments.includes("oauth2") || isDevBuild(buildInfo)) && (
<SidebarNavItem href="/deployment/oauth2-provider/apps">
OAuth2 Applications
</SidebarNavItem>
)}
{permissions.viewDeploymentConfig && (
<SidebarNavItem href="/deployment/network">Network</SidebarNavItem>
)}
@@ -141,6 +141,7 @@ export const EditOAuth2AppPageView: FC<EditOAuth2AppProps> = ({
confirmLoading={mutatingResource.deleteApp}
name={app.name}
entity="OAuth2 application"
info="Deleting this OAuth2 application will immediately invalidate all active sessions and API keys associated with it. Users currently authenticated through this application will be logged out and need to re-authenticate."
onConfirm={() => deleteApp(app.name)}
onCancel={() => setShowDelete(false)}
/>
+8 -1
View File
@@ -13,17 +13,19 @@ import {
FingerprintIcon,
KeyIcon,
LockIcon,
ShieldIcon,
UserIcon,
} from "lucide-react";
import { useDashboard } from "modules/dashboard/useDashboard";
import type { FC } from "react";
import { isDevBuild } from "utils/buildInfo";
interface SidebarProps {
user: User;
}
export const Sidebar: FC<SidebarProps> = ({ user }) => {
const { entitlements } = useDashboard();
const { entitlements, experiments, buildInfo } = useDashboard();
const showSchedulePage =
entitlements.features.advanced_template_scheduling.enabled;
@@ -43,6 +45,11 @@ export const Sidebar: FC<SidebarProps> = ({ user }) => {
<SidebarNavItem href="external-auth" icon={GitIcon}>
External Authentication
</SidebarNavItem>
{(experiments.includes("oauth2") || isDevBuild(buildInfo)) && (
<SidebarNavItem href="oauth2-provider" icon={ShieldIcon}>
OAuth2 Applications
</SidebarNavItem>
)}
{showSchedulePage && (
<SidebarNavItem href="schedule" icon={CalendarCogIcon}>
Schedule
@@ -13,6 +13,7 @@ import type { FC } from "react";
import { type Location, useLocation } from "react-router-dom";
import {
MockAppearanceConfig,
MockBuildInfo,
MockDeploymentConfig,
MockEntitlements,
MockFailedWorkspace,
@@ -554,6 +555,10 @@ describe("WorkspacePage", () => {
appearance: MockAppearanceConfig,
entitlements: MockEntitlements,
experiments: [],
buildInfo: {
...MockBuildInfo,
version: "v0.0.0-test",
},
organizations: [MockOrganization],
showOrganizations: true,
canViewOrganizationSettings: true,
+5
View File
@@ -18,6 +18,7 @@ import type { FC } from "react";
import { useQueryClient } from "react-query";
import {
MockAppearanceConfig,
MockBuildInfo,
MockDefaultOrganization,
MockDeploymentConfig,
MockEntitlements,
@@ -56,6 +57,10 @@ export const withDashboardProvider = (
entitlements,
experiments,
appearance: MockAppearanceConfig,
buildInfo: {
...MockBuildInfo,
version: "v0.0.0-test",
},
organizations,
showOrganizations,
canViewOrganizationSettings,
+13
View File
@@ -22,3 +22,16 @@ export const getStaticBuildInfo = () => {
return CACHED_BUILD_INFO;
};
// Check if the current build is a development build.
// Development builds have versions containing "-devel" or "v0.0.0".
// This matches the backend's buildinfo.IsDev() logic.
export const isDevBuild = (input: BuildInfoResponse): boolean => {
const version = input.version;
if (!version) {
return false;
}
// Check for dev version pattern (contains "-devel") or no version (v0.0.0)
return version.includes("-devel") || version === "v0.0.0";
};