diff --git a/plugins/backstage-plugin-coder/package.json b/plugins/backstage-plugin-coder/package.json
index e48c8f21..e91fe5be 100644
--- a/plugins/backstage-plugin-coder/package.json
+++ b/plugins/backstage-plugin-coder/package.json
@@ -41,6 +41,8 @@
     "@material-ui/icons": "^4.9.1",
     "@material-ui/lab": "4.0.0-alpha.61",
     "@tanstack/react-query": "4.36.1",
+    "axios": "^1.6.8",
+    "use-sync-external-store": "^1.2.0",
     "valibot": "^0.28.1"
   },
   "peerDependencies": {
diff --git a/plugins/backstage-plugin-coder/src/api/CoderClient.test.tsx b/plugins/backstage-plugin-coder/src/api/CoderClient.test.tsx
new file mode 100644
index 00000000..8f80dc51
--- /dev/null
+++ b/plugins/backstage-plugin-coder/src/api/CoderClient.test.tsx
@@ -0,0 +1,211 @@
+import React, { useEffect } from 'react';
+import { useSyncExternalStore } from 'use-sync-external-store/shim';
+import { act, render, waitFor } from '@testing-library/react';
+import {
+  getMockDiscoveryApi,
+  getMockIdentityApi,
+  mockBackstageUrlRoot,
+  mockCoderAuthToken,
+  setupCoderClient,
+} from '../testHelpers/mockBackstageData';
+import {
+  CoderClient,
+  CoderClientSnapshot,
+  defaultCoderClientConfigOptions,
+} from './CoderClient';
+import { CoderTokenAuth } from './CoderTokenAuth';
+import type { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api';
+import { CoderAuthApi } from './Auth';
+import { server, wrappedGet } from '../testHelpers/server';
+
+type SetupClientInput = Readonly<{
+  authApi?: CoderAuthApi;
+  discoveryApi?: DiscoveryApi;
+}>;
+
+type SetupClientOutput = Readonly<{
+  discoveryApi: DiscoveryApi;
+  identityApi: IdentityApi;
+  coderClientApi: CoderClient;
+}>;
+
+function setupClient(options?: SetupClientInput): SetupClientOutput {
+  const {
+    authApi = new CoderTokenAuth(),
+    discoveryApi = getMockDiscoveryApi(),
+  } = options ?? {};
+
+  const identityApi = getMockIdentityApi();
+  const { coderClientApi } = setupCoderClient({
+    discoveryApi,
+    identityApi,
+    authApi,
+  });
+
+  return { discoveryApi, identityApi, coderClientApi };
+}
+
+/**
+ * @todo Decide if we want to test the SDK-like functionality (even as a
+ * stopgap). Once we can import the methods from Coder, it might be safe for the
+ * plugin to assume the methods will always work.
+ *
+ * Plus, the other test files making requests to the SDK to get specific data
+ * should kick up any other issues.
+ */
+describe(`${CoderClient.name}`, () => {
+  /**
+   * Once the OAuth implementation is done, it probably makes sense to have test
+   * cases specifically for that.
+   */
+  describe('With token auth', () => {
+    describe('validateAuth method', () => {
+      it('Will update the underlying auth instance when a query succeeds', async () => {
+        const authApi = new CoderTokenAuth();
+        const { coderClientApi } = setupClient({ authApi });
+
+        authApi.registerNewToken(mockCoderAuthToken);
+        const validationResult = await coderClientApi.validateAuth();
+
+        expect(validationResult).toBe(true);
+        expect(authApi.isTokenValid).toBe(true);
+
+        const clientSnapshot = coderClientApi.getStateSnapshot();
+        expect(clientSnapshot).toEqual(
+          expect.objectContaining<Partial<CoderClientSnapshot>>({
+            isAuthValid: true,
+          }),
+        );
+      });
+
+      it('Will update the underlying auth instance when a query fails', async () => {
+        const authApi = new CoderTokenAuth();
+        const { coderClientApi } = setupClient({ authApi });
+
+        authApi.registerNewToken('Definitely not a valid token');
+        const validationResult = await coderClientApi.validateAuth();
+
+        expect(validationResult).toBe(false);
+        expect(authApi.isTokenValid).toBe(false);
+
+        const clientSnapshot = coderClientApi.getStateSnapshot();
+        expect(clientSnapshot).toEqual(
+          expect.objectContaining<Partial<CoderClientSnapshot>>({
+            isAuthValid: false,
+          }),
+        );
+      });
+    });
+  });
+
+  describe('State snapshot subscriptions', () => {
+    it('Lets external systems subscribe to state changes', async () => {
+      const { coderClientApi } = setupClient();
+      const onChange = jest.fn();
+      coderClientApi.subscribe(onChange);
+
+      await coderClientApi.validateAuth();
+      expect(onChange).toHaveBeenCalled();
+    });
+
+    it('Lets external systems UN-subscribe to state changes', async () => {
+      const authApi = new CoderTokenAuth();
+      const { coderClientApi } = setupClient({ authApi });
+
+      const subscriber1 = jest.fn();
+      const subscriber2 = jest.fn();
+
+      /**
+       * Doing something a little sneaky to try accounting for something that
+       * could happen in the real world. The setup is:
+       *
+       * 1. External system subscribes to client
+       * 2. Client calls validateAuth, which is async and goes through the
+       *    microtask queue
+       * 3. During that brief window where we're waiting for the response to
+       *    come back, the external system unsubscribes
+       * 4. Promise resolves, and the auth state changes, but the old subscriber
+       *    should *NOT* get notified because it's unsubscribed now
+       */
+      coderClientApi.subscribe(subscriber1);
+      coderClientApi.subscribe(subscriber2);
+
+      // Important that there's no await here. Do not want to pause the thread
+      // of execution until after subscriber2 unsubscribes.
+      void coderClientApi.validateAuth();
+      coderClientApi.unsubscribe(subscriber2);
+
+      await waitFor(() => expect(subscriber1).toHaveBeenCalled());
+      expect(subscriber2).not.toHaveBeenCalled();
+    });
+
+    it('Provides tools to let React components bind re-renders to state changes', async () => {
+      const { coderClientApi } = setupClient();
+      const onStateChange = jest.fn();
+
+      const DummyReactComponent = () => {
+        const reactiveStateSnapshot = useSyncExternalStore(
+          coderClientApi.subscribe,
+          coderClientApi.getStateSnapshot,
+        );
+
+        useEffect(() => {
+          onStateChange();
+        }, [reactiveStateSnapshot]);
+
+        return null;
+      };
+
+      const { rerender } = render(<DummyReactComponent />);
+      expect(onStateChange).toHaveBeenCalledTimes(1);
+
+      await act(() => coderClientApi.validateAuth());
+      expect(onStateChange).toHaveBeenCalledTimes(2);
+
+      // Make sure that if the component re-renders from the top down (like a
+      // parent state change), that does not cause the snapshot to lose its
+      // stable reference
+      rerender(<DummyReactComponent />);
+      expect(onStateChange).toHaveBeenCalledTimes(2);
+    });
+
+    it('Will notify external systems when the DiscoveryApi base URL has changed between requests', async () => {
+      // The Backstage docs say that the values returned by the Discovery API
+      // can change over time, which is why they want you to call it fresh
+      // before every request, but none of their public interfaces allow you to
+      // test that super well
+      let currentBaseUrl = mockBackstageUrlRoot;
+      const mockDiscoveryApi: DiscoveryApi = {
+        getBaseUrl: async () => currentBaseUrl,
+      };
+
+      const authApi = new CoderTokenAuth();
+      const { coderClientApi } = setupClient({
+        discoveryApi: mockDiscoveryApi,
+      });
+
+      const onChange = jest.fn();
+      coderClientApi.subscribe(onChange);
+      authApi.registerNewToken(mockCoderAuthToken);
+
+      const newBaseUrl = 'https://www.zombo.com/api/you-can-do-anything';
+      const serverRouteUrl = `${newBaseUrl}${defaultCoderClientConfigOptions.proxyPrefix}${defaultCoderClientConfigOptions.apiRoutePrefix}/users/me/login-type`;
+
+      server.use(
+        wrappedGet(serverRouteUrl, (_, res, ctx) => {
+          return res(ctx.status(200));
+        }),
+      );
+
+      currentBaseUrl = newBaseUrl;
+      await coderClientApi.validateAuth();
+
+      expect(onChange).toHaveBeenCalledWith(
+        expect.objectContaining<Partial<CoderClientSnapshot>>({
+          assetsRoute: expect.stringContaining(newBaseUrl),
+          apiRoute: expect.stringContaining(newBaseUrl),
+        }),
+      );
+    });
+  });
+});
diff --git a/plugins/backstage-plugin-coder/src/api/CoderClient.ts b/plugins/backstage-plugin-coder/src/api/CoderClient.ts
new file mode 100644
index 00000000..37d8b132
--- /dev/null
+++ b/plugins/backstage-plugin-coder/src/api/CoderClient.ts
@@ -0,0 +1,393 @@
+/**
+ * @file This class is a little chaotic. It's basically in charge of juggling
+ * and coordinating several different systems together:
+ *
+ * 1. Backstage APIs (API classes/factories, as well as proxies)
+ * 2. React (making sure that mutable class state can be turned into immutable
+ *    state snapshots that are available synchronously from the first render)
+ * 3. The custom auth API(s) that we build out for Backstage
+ * 4. The Coder SDK (either the eventual real one, or the fake stopgap)
+ * 5. Axios (which we need, because it's what the Coder SDK uses)
+ *
+ * All while being easy for the end-user to drop into their own Backstage
+ * deployment.
+ */
+import globalAxios, {
+  type InternalAxiosRequestConfig,
+  AxiosError,
+} from 'axios';
+import {
+  type DiscoveryApi,
+  type IdentityApi,
+  createApiRef,
+} from '@backstage/core-plugin-api';
+import { BackstageHttpError } from './errors';
+
+import type { CoderAuthApi } from './Auth';
+import {
+  type UserLoginType,
+  type Workspace,
+  type WorkspaceBuildParameter,
+  type WorkspacesRequest,
+  type WorkspacesResponse,
+  CODER_API_REF_ID_PREFIX,
+} from '../typesConstants';
+import { StateSnapshotManager } from '../utils/StateSnapshotManager';
+import type { CoderWorkspacesConfig } from '../hooks/useCoderWorkspacesConfig';
+
+type CoderClientConfigOptions = Readonly<{
+  proxyPrefix: string;
+  apiRoutePrefix: string;
+  authHeaderKey: string;
+  assetsRoutePrefix: string;
+  requestTimeoutMs: number;
+}>;
+
+export const defaultCoderClientConfigOptions = {
+  proxyPrefix: '/coder',
+  apiRoutePrefix: '/api/v2',
+  assetsRoutePrefix: '/', // Deliberately left as single slash
+  authHeaderKey: 'Coder-Session-Token',
+  requestTimeoutMs: 20_000,
+} as const satisfies CoderClientConfigOptions;
+
+export type CoderClientSnapshot = Readonly<{
+  isAuthValid: boolean;
+  apiRoute: string;
+  assetsRoute: string;
+}>;
+
+/**
+ * @todo This should eventually be the real Coder SDK.
+ */
+type RawCoderSdkApi = {
+  getUserLoginType: () => Promise<UserLoginType>;
+  getWorkspaces: (options: WorkspacesRequest) => Promise<WorkspacesResponse>;
+  getWorkspaceBuildParameters: (
+    input: string,
+  ) => Promise<readonly WorkspaceBuildParameter[]>;
+};
+
+/**
+ * A version of the main Coder SDK API, with additional Backstage-specific
+ * methods and properties.
+ */
+export type BackstageCoderSdkApi = Readonly<
+  RawCoderSdkApi & {
+    getWorkspacesByRepo: (
+      coderQuery: string,
+      config: CoderWorkspacesConfig,
+    ) => Promise<readonly Workspace[]>;
+  }
+>;
+
+type SubscriptionCallback = (newSnapshot: CoderClientSnapshot) => void;
+
+export type CoderClientApi = Readonly<{
+  sdkApi: BackstageCoderSdkApi;
+  isAuthValid: boolean;
+  validateAuth: () => Promise<boolean>;
+
+  getStateSnapshot: () => CoderClientSnapshot;
+  unsubscribe: (callback: SubscriptionCallback) => void;
+  subscribe: (callback: SubscriptionCallback) => () => void;
+  cleanupClient: () => void;
+}>;
+
+/**
+ * @todo Using an Axios instance to ensure that even if another user is using
+ * Axios, there's no risk of our request intercepting logic messing up non-Coder
+ * requests.
+ *
+ * However, the current version of the SDK does NOT have this behavior. Make
+ * sure that it does when it finally gets built out.
+ */
+const axiosInstance = globalAxios.create();
+
+type CoderClientConstructorInputs = Partial<CoderClientConfigOptions> & {
+  apis: Readonly<{
+    identityApi: IdentityApi;
+    discoveryApi: DiscoveryApi;
+    authApi: CoderAuthApi;
+  }>;
+};
+
+export class CoderClient implements CoderClientApi {
+  private readonly identityApi: IdentityApi;
+  private readonly discoveryApi: DiscoveryApi;
+  private readonly authApi: CoderAuthApi;
+
+  private readonly options: CoderClientConfigOptions;
+  private readonly snapshotManager: StateSnapshotManager<CoderClientSnapshot>;
+  private readonly axiosInterceptorId: number;
+  private readonly abortController: AbortController;
+
+  private latestProxyEndpoint: string;
+  readonly sdkApi: BackstageCoderSdkApi;
+
+  /* ***************************************************************************
+   * There is some funky (but necessary) stuff going on in this class - a lot of
+   * the class methods are passed directly to other systems. Just to be on the
+   * safe side, all methods (public and private) should be defined as arrow
+   * functions, to ensure the methods can't ever lose their `this` contexts
+   *
+   * This technically defeats some of the memory optimizations you would
+   * normally get with class methods (arrow methods will be rebuilt from
+   * scratch each time the class is instantiated), but because CoderClient will
+   * likely be instantiated only once for the entire app's lifecycle, that won't
+   * matter much at all
+   ****************************************************************************/
+
+  constructor(inputs: CoderClientConstructorInputs) {
+    const { apis, ...options } = inputs;
+    const { discoveryApi, identityApi, authApi } = apis;
+
+    // The "easy setup" part - initialize internal properties
+    this.identityApi = identityApi;
+    this.discoveryApi = discoveryApi;
+    this.authApi = authApi;
+    this.latestProxyEndpoint = '';
+    this.options = { ...defaultCoderClientConfigOptions, ...options };
+
+    /**
+     * Wire up SDK API namespace.
+     *
+     * @todo All methods are defined locally in the class, but this should
+     * eventually be updated so that 99% of methods come from the SDK, with a
+     * few extra methods patched in for Backstage convenience
+     */
+    this.sdkApi = {
+      getUserLoginType: this.getUserLoginType,
+      getWorkspaceBuildParameters: this.getWorkspaceBuildParameters,
+      getWorkspaces: this.getWorkspaces,
+      getWorkspacesByRepo: this.getWorkspacesByRepo,
+    };
+
+    // Wire up Backstage APIs and Axios to be aware of each other
+    this.abortController = new AbortController();
+    this.axiosInterceptorId = axiosInstance.interceptors.request.use(
+      this.interceptAxiosRequest,
+    );
+
+    // Hook up snapshot manager so that external systems can be made aware when
+    // state changes, all in a render-safe way
+    this.snapshotManager = new StateSnapshotManager({
+      initialSnapshot: this.prepareNewStateSnapshot(),
+    });
+
+    // Set up logic for syncing client snapshots to auth state changes
+    this.authApi.subscribe(newAuthSnapshot => {
+      const latestClientSnapshot = this.getStateSnapshot();
+      if (newAuthSnapshot.isTokenValid !== latestClientSnapshot.isAuthValid) {
+        this.notifySubscriptionsOfStateChange();
+      }
+    });
+
+    // Call DiscoveryApi to populate initial endpoint path, so that the path
+    // can be accessed synchronously from the UI. Should be called last after
+    // all other initialization steps
+    void this.getProxyEndpoint();
+  }
+
+  get isAuthValid(): boolean {
+    return this.authApi.isTokenValid;
+  }
+
+  // Request configs are created on the per-request basis, so mutating a config
+  // won't mess up future non-Coder requests that also uses Axios
+  private interceptAxiosRequest = async (
+    config: InternalAxiosRequestConfig,
+  ): Promise<InternalAxiosRequestConfig> => {
+    const { authHeaderKey, apiRoutePrefix } = this.options;
+
+    const proxyEndpoint = await this.getProxyEndpoint();
+    config.baseURL = `${proxyEndpoint}${apiRoutePrefix}`;
+    config.signal = this.abortController.signal;
+    config.headers[authHeaderKey] = this.authApi.requestToken() ?? undefined;
+
+    const bearerToken = (await this.identityApi.getCredentials()).token;
+    if (bearerToken) {
+      config.headers.Authorization = `Bearer ${bearerToken}`;
+    }
+
+    return config;
+  };
+
+  private prepareNewStateSnapshot = (): CoderClientSnapshot => {
+    const base = this.latestProxyEndpoint;
+    const { apiRoutePrefix, assetsRoutePrefix } = this.options;
+
+    return {
+      isAuthValid: this.authApi.isTokenValid,
+      apiRoute: `${base}${apiRoutePrefix}`,
+      assetsRoute: `${base}${assetsRoutePrefix}`,
+    };
+  };
+
+  private notifySubscriptionsOfStateChange = (): void => {
+    const newSnapshot = this.prepareNewStateSnapshot();
+    this.snapshotManager.updateSnapshot(newSnapshot);
+  };
+
+  // Backstage officially recommends that you use the DiscoveryApi over the
+  // ConfigApi nowadays, and that you call it before each request. But the
+  // problem is that the Discovery API has no synchronous methods for getting
+  // endpoints, meaning that there's no great built-in way to access that data
+  // from the UI's render logic. Have to cache the return value to close the gap
+  private getProxyEndpoint = async (): Promise<string> => {
+    const latestBase = await this.discoveryApi.getBaseUrl('proxy');
+    const withProxy = `${latestBase}${this.options.proxyPrefix}`;
+
+    this.latestProxyEndpoint = withProxy;
+    this.notifySubscriptionsOfStateChange();
+
+    return withProxy;
+  };
+
+  private getUserLoginType = async (): Promise<UserLoginType> => {
+    const response = await axiosInstance.get<UserLoginType>(
+      '/users/me/login-type',
+    );
+
+    return response.data;
+  };
+
+  private remapWorkspaceIconUrls = (
+    workspaces: readonly Workspace[],
+  ): Workspace[] => {
+    const { assetsRoute } = this.getStateSnapshot();
+
+    return workspaces.map(ws => {
+      const templateIconUrl = ws.template_icon;
+      if (!templateIconUrl.startsWith('/')) {
+        return ws;
+      }
+
+      return {
+        ...ws,
+        template_icon: `${assetsRoute}${templateIconUrl}`,
+      };
+    });
+  };
+
+  private getWorkspaceBuildParameters = async (
+    workspaceBuildId: string,
+  ): Promise<readonly WorkspaceBuildParameter[]> => {
+    const response = await axiosInstance.get<
+      readonly WorkspaceBuildParameter[]
+    >(`/workspacebuilds/${workspaceBuildId}/parameters`);
+
+    return response.data;
+  };
+
+  private getWorkspaces = async (
+    options: WorkspacesRequest,
+  ): Promise<WorkspacesResponse> => {
+    const urlParams = new URLSearchParams({
+      q: options.q ?? '',
+      limit: String(options.limit || 0),
+    });
+
+    const { data } = await axiosInstance.get<WorkspacesResponse>(
+      `/workspaces?${urlParams.toString()}`,
+    );
+
+    const remapped: WorkspacesResponse = {
+      ...data,
+      workspaces: this.remapWorkspaceIconUrls(data.workspaces),
+    };
+
+    return remapped;
+  };
+
+  private getWorkspacesByRepo = async (
+    coderQuery: string,
+    config: CoderWorkspacesConfig,
+  ): Promise<readonly Workspace[]> => {
+    const { workspaces } = await this.getWorkspaces({
+      q: coderQuery,
+      limit: 0,
+    });
+
+    const remappedWorkspaces = this.remapWorkspaceIconUrls(workspaces);
+    const paramResults = await Promise.allSettled(
+      remappedWorkspaces.map(ws =>
+        this.sdkApi.getWorkspaceBuildParameters(ws.latest_build.id),
+      ),
+    );
+
+    const matchedWorkspaces: Workspace[] = [];
+    for (const [index, res] of paramResults.entries()) {
+      if (res.status === 'rejected') {
+        continue;
+      }
+
+      for (const param of res.value) {
+        const include =
+          config.repoUrlParamKeys.includes(param.name) &&
+          param.value === config.repoUrl;
+
+        if (include) {
+          // Doing type assertion just in case noUncheckedIndexedAccess compiler
+          // setting ever gets turned on; this shouldn't ever break, but it's
+          // technically not type-safe
+          matchedWorkspaces.push(workspaces[index] as Workspace);
+          break;
+        }
+      }
+    }
+
+    return matchedWorkspaces;
+  };
+
+  unsubscribe = (callback: SubscriptionCallback): void => {
+    this.snapshotManager.unsubscribe(callback);
+  };
+
+  subscribe = (callback: SubscriptionCallback): (() => void) => {
+    return this.snapshotManager.subscribe(callback);
+  };
+
+  getStateSnapshot = (): CoderClientSnapshot => {
+    return this.snapshotManager.getSnapshot();
+  };
+
+  validateAuth = async (): Promise<boolean> => {
+    const dispatchNewStatus = this.authApi.getAuthStateSetter();
+
+    try {
+      // Dummy request; just need something that all users would have access
+      // to, and that doesn't require a body
+      await this.sdkApi.getUserLoginType();
+      dispatchNewStatus(true);
+      return true;
+    } catch (err) {
+      dispatchNewStatus(false);
+
+      if (!(err instanceof AxiosError)) {
+        throw err;
+      }
+
+      const response = err.response;
+      if (response === undefined) {
+        err.message = `No Axios response to reference - ${err.message}`;
+        throw err;
+      }
+
+      if (response.status >= 400 && response.status !== 401) {
+        throw new BackstageHttpError('Failed to complete request', response);
+      }
+    }
+
+    return false;
+  };
+
+  cleanupClient = () => {
+    this.abortController.abort();
+    axiosInstance.interceptors.request.eject(this.axiosInterceptorId);
+  };
+}
+
+export const coderClientApiRef = createApiRef<CoderClient>({
+  id: `${CODER_API_REF_ID_PREFIX}.coder-client`,
+});
diff --git a/plugins/backstage-plugin-coder/src/api/errors.ts b/plugins/backstage-plugin-coder/src/api/errors.ts
new file mode 100644
index 00000000..854d7283
--- /dev/null
+++ b/plugins/backstage-plugin-coder/src/api/errors.ts
@@ -0,0 +1,32 @@
+import type { AxiosHeaderValue, AxiosResponse } from 'axios';
+
+/**
+ * Makes it easier to expose HTTP responses in the event of errors and also
+ * gives TypeScript a faster way to type-narrow on those errors
+ */
+export class BackstageHttpError extends Error {
+  #failedResponse: AxiosResponse;
+
+  constructor(errorMessage: string, failedResponse: AxiosResponse) {
+    super(errorMessage);
+    this.name = 'BackstageHttpError';
+    this.#failedResponse = failedResponse;
+  }
+
+  static isInstance(value: unknown): value is BackstageHttpError {
+    return value instanceof BackstageHttpError;
+  }
+
+  get status(): number {
+    return this.#failedResponse.status;
+  }
+
+  get ok(): boolean {
+    const status = this.#failedResponse.status;
+    return !(status >= 200 && status <= 299);
+  }
+
+  get contentType(): AxiosHeaderValue | undefined {
+    return this.#failedResponse.headers['Content-Type'];
+  }
+}
diff --git a/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts b/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts
index f36ce2da..e2dbc4a5 100644
--- a/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts
+++ b/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts
@@ -1,5 +1,5 @@
 /* eslint-disable @backstage/no-undeclared-imports -- For test helpers only */
-import { ConfigReader } from '@backstage/core-app-api';
+import { ConfigReader, FrontendHostDiscovery } from '@backstage/core-app-api';
 import { MockConfigApi, MockErrorApi } from '@backstage/test-utils';
 import type { ScmIntegrationRegistry } from '@backstage/integration';
 /* eslint-enable @backstage/no-undeclared-imports */
@@ -17,7 +17,10 @@ import {
 import { ScmIntegrationsApi } from '@backstage/integration-react';
 
 import { API_ROUTE_PREFIX, ASSETS_ROUTE_PREFIX } from '../api';
-import { IdentityApi } from '@backstage/core-plugin-api';
+import { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api';
+import { CoderAuthApi } from '../api/Auth';
+import { CoderClient } from '../api/CoderClient';
+import { CoderTokenAuth } from '../api/CoderTokenAuth';
 
 /**
  * This is the key that Backstage checks from the entity data to determine the
@@ -285,3 +288,71 @@ export function getMockLocalStorage(
     },
   };
 }
+
+export function getMockDiscoveryApi(): DiscoveryApi {
+  return FrontendHostDiscovery.fromConfig(
+    new ConfigReader({
+      backend: {
+        baseUrl: mockBackstageUrlRoot,
+      },
+    }),
+  );
+}
+
+export function getMockCoderTokenAuth(): CoderTokenAuth {
+  return new CoderTokenAuth({
+    localStorage: getMockLocalStorage(),
+  });
+}
+
+type SetupCoderClientInputs = Readonly<{
+  discoveryApi?: DiscoveryApi;
+  identityApi?: IdentityApi;
+  authApi?: CoderAuthApi;
+}>;
+
+type SetupCoderClientResult = Readonly<{
+  authApi: CoderAuthApi;
+  coderClientApi: CoderClient;
+}>;
+
+/**
+ * @todo 2024-04-23 - This is a workaround for making sure that the Axios
+ * instance doesn't get overloaded with different request interceptors from each
+ * test case.
+ *
+ * The SDK value we'll eventually be grabbing (and its Axios instance) are
+ * basically set up as a global singleton, which means that you get less ability
+ * to do test isolation. Better to make the updates upstream so that the SDK
+ * can be re-instantiated for different tests, and then have the garbage
+ * collector handle disposing all of the values.
+ */
+const activeClients = new Set<CoderClient>();
+afterEach(() => {
+  activeClients.forEach(client => client.cleanupClient());
+  activeClients.clear();
+});
+
+/**
+ * Gives back a Coder Client, its underlying auth implementation, and also
+ * handles cleanup for the Coder client between test runs.
+ *
+ * It is strongly recommended that you create all Coder clients via this
+ * function.
+ */
+export function setupCoderClient({
+  authApi = getMockCoderTokenAuth(),
+  discoveryApi = getMockDiscoveryApi(),
+  identityApi = getMockIdentityApi(),
+}: SetupCoderClientInputs): SetupCoderClientResult {
+  const mockCoderClientApi = new CoderClient({
+    apis: { identityApi, discoveryApi, authApi },
+  });
+
+  activeClients.add(mockCoderClientApi);
+
+  return {
+    authApi,
+    coderClientApi: mockCoderClientApi,
+  };
+}
diff --git a/plugins/backstage-plugin-coder/src/testHelpers/server.ts b/plugins/backstage-plugin-coder/src/testHelpers/server.ts
index 99db7c1b..aac3964d 100644
--- a/plugins/backstage-plugin-coder/src/testHelpers/server.ts
+++ b/plugins/backstage-plugin-coder/src/testHelpers/server.ts
@@ -19,8 +19,12 @@ import {
   mockCoderAuthToken,
   mockBackstageProxyEndpoint as root,
 } from './mockBackstageData';
-import type { Workspace, WorkspacesResponse } from '../typesConstants';
-import { CODER_AUTH_HEADER_KEY } from '../api';
+import { defaultCoderClientConfigOptions } from '../api/CoderClient';
+import type {
+  Workspace,
+  WorkspacesResponse,
+  UserLoginType,
+} from '../typesConstants';
 
 type RestResolver<TBody extends DefaultBodyType = any> = ResponseResolver<
   RestRequest<TBody>,
@@ -33,6 +37,18 @@ export type RestResolverMiddleware<TBody extends DefaultBodyType = any> = (
 ) => RestResolver<TBody>;
 
 const defaultMiddleware = [
+  function validateCoderSessionToken(handler) {
+    return (req, res, ctx) => {
+      const headerKey = defaultCoderClientConfigOptions.authHeaderKey;
+      const token = req.headers.get(headerKey);
+
+      if (token === mockCoderAuthToken) {
+        return handler(req, res, ctx);
+      }
+
+      return res(ctx.status(401));
+    };
+  },
   function validateBearerToken(handler) {
     return (req, res, ctx) => {
       const tokenRe = /^Bearer (.+)$/;
@@ -59,7 +75,7 @@ export function wrapInDefaultMiddleware<TBody extends DefaultBodyType = any>(
   }, resolver);
 }
 
-function wrappedGet<TBody extends DefaultBodyType = any>(
+export function wrappedGet<TBody extends DefaultBodyType = any>(
   path: string,
   resolver: RestResolver<TBody>,
 ): RestHandler {
@@ -103,14 +119,20 @@ const mainTestHandlers: readonly RestHandler[] = [
     },
   ),
 
-  // This is the dummy request used to verify a user's auth status
-  wrappedGet(`${root}/users/me`, (req, res, ctx) => {
-    const token = req.headers.get(CODER_AUTH_HEADER_KEY);
-    if (token === mockCoderAuthToken) {
-      return res(ctx.status(200));
-    }
+  // This is the old dummy request used to verify a user's auth status
+  wrappedGet(`${root}/users/me`, (_, res, ctx) => {
+    return res(ctx.status(200));
+  }),
 
-    return res(ctx.status(401));
+  // This is the new dummy request used to verify a user's auth status that the
+  // Coder SDK will use
+  wrappedGet(`${root}/users/me/login-type`, (_req, res, ctx) => {
+    return res(
+      ctx.status(200),
+      ctx.json<UserLoginType>({
+        login_type: 'token',
+      }),
+    );
   }),
 ];
 
diff --git a/plugins/backstage-plugin-coder/src/typesConstants.ts b/plugins/backstage-plugin-coder/src/typesConstants.ts
index ece3bbb0..777c9bc0 100644
--- a/plugins/backstage-plugin-coder/src/typesConstants.ts
+++ b/plugins/backstage-plugin-coder/src/typesConstants.ts
@@ -92,3 +92,28 @@ export type WorkspacesResponse = Output<typeof workspacesResponseSchema>;
 export type WorkspaceBuildParameter = Output<
   typeof workspaceBuildParameterSchema
 >;
+
+/**
+ * @todo Replace these type definitions with the full Coder SDK API once we have
+ * that built out and ready to import into other projects. Be sure to export out
+ * all type definitions from the API under a single namespace, too. (e.g.,
+ * export type * as CoderSdkTypes from 'coder-ts-sdk')
+ *
+ * The types for RawCoderSdkApi should only include functions/values that exist
+ * on the current "pseudo-SDK" found in the main coder/coder repo, and that are
+ * likely to carry over to the full SDK.
+ *
+ * @see {@link https://github.com/coder/coder/tree/main/site/src/api}
+ */
+export type WorkspacesRequest = Readonly<{
+  after_id?: string;
+  limit?: number;
+  offset?: number;
+  q?: string;
+}>;
+
+// Return value used for the dummy requests used to verify a user's auth status
+// for the Coder token auth logic
+export type UserLoginType = Readonly<{
+  login_type: '' | 'github' | 'none' | 'oidc' | 'password' | 'token';
+}>;
diff --git a/yarn.lock b/yarn.lock
index a60186cb..d11bc7d9 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -8713,7 +8713,7 @@
   resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.7.tgz#50ae4353eaaddc04044279812f52c8c65857dbcb"
   integrity sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==
 
-"@types/react-dom@*", "@types/react-dom@^18", "@types/react-dom@^18.0.0":
+"@types/react-dom@*", "@types/react-dom@^18.0.0":
   version "18.2.21"
   resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.21.tgz#b8c81715cebdebb2994378616a8d54ace54f043a"
   integrity sha512-gnvBA/21SA4xxqNXEwNiVcP0xSGHh/gi1VhWv9Bl46a0ItbTT5nFY+G9VSQpaG/8N/qdJpJ+vftQ4zflTtnjLw==
@@ -8751,7 +8751,7 @@
   dependencies:
     "@types/react" "*"
 
-"@types/react@*", "@types/react@^16.13.1 || ^17.0.0", "@types/react@^16.13.1 || ^17.0.0 || ^18.0.0", "@types/react@^18":
+"@types/react@*", "@types/react@^16.13.1 || ^17.0.0 || ^18.0.0":
   version "18.2.64"
   resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.64.tgz#3700fbb6b2fa60a6868ec1323ae4cbd446a2197d"
   integrity sha512-MlmPvHgjj2p3vZaxbQgFUQFvD8QiZwACfGqEdDSWou5yISWxDQ4/74nCAwsUiX7UFLKZz3BbVSPj+YxeoGGCfg==
@@ -8760,6 +8760,15 @@
     "@types/scheduler" "*"
     csstype "^3.0.2"
 
+"@types/react@^16.13.1 || ^17.0.0":
+  version "17.0.80"
+  resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.80.tgz#a5dfc351d6a41257eb592d73d3a85d3b7dbcbb41"
+  integrity sha512-LrgHIu2lEtIo8M7d1FcI3BdwXWoRQwMoXOZ7+dPTW0lYREjmlHl3P0U1VD0i/9tppOuv8/sam7sOjx34TxSFbA==
+  dependencies:
+    "@types/prop-types" "*"
+    "@types/scheduler" "^0.16"
+    csstype "^3.0.2"
+
 "@types/request@^2.47.1", "@types/request@^2.48.8":
   version "2.48.12"
   resolved "https://registry.yarnpkg.com/@types/request/-/request-2.48.12.tgz#0f590f615a10f87da18e9790ac94c29ec4c5ef30"
@@ -8787,7 +8796,7 @@
   resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d"
   integrity sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==
 
-"@types/scheduler@*":
+"@types/scheduler@*", "@types/scheduler@^0.16":
   version "0.16.8"
   resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.8.tgz#ce5ace04cfeabe7ef87c0091e50752e36707deff"
   integrity sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==
@@ -9951,6 +9960,15 @@ axios@^1.0.0, axios@^1.4.0, axios@^1.6.0:
     form-data "^4.0.0"
     proxy-from-env "^1.1.0"
 
+axios@^1.6.8:
+  version "1.6.8"
+  resolved "https://registry.yarnpkg.com/axios/-/axios-1.6.8.tgz#66d294951f5d988a00e87a0ffb955316a619ea66"
+  integrity sha512-v/ZHtJDU39mDpyBoFVkETcd/uNdxrWRrg3bKpOKzXFA6Bvqopts6ALSMU3y6ijYxbw2B+wPrIv46egTzJXCLGQ==
+  dependencies:
+    follow-redirects "^1.15.6"
+    form-data "^4.0.0"
+    proxy-from-env "^1.1.0"
+
 axobject-query@^3.2.1:
   version "3.2.1"
   resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-3.2.1.tgz#39c378a6e3b06ca679f29138151e45b2b32da62a"
@@ -13519,6 +13537,11 @@ follow-redirects@^1.0.0, follow-redirects@^1.14.0, follow-redirects@^1.15.4:
   resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.5.tgz#54d4d6d062c0fa7d9d17feb008461550e3ba8020"
   integrity sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==
 
+follow-redirects@^1.15.6:
+  version "1.15.6"
+  resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.6.tgz#7f815c0cda4249c74ff09e95ef97c23b5fd0399b"
+  integrity sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==
+
 for-each@^0.3.3:
   version "0.3.3"
   resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e"
@@ -21890,16 +21913,7 @@ string-length@^4.0.1:
     char-regex "^1.0.2"
     strip-ansi "^6.0.0"
 
-"string-width-cjs@npm:string-width@^4.2.0":
-  version "4.2.3"
-  resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
-  integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
-  dependencies:
-    emoji-regex "^8.0.0"
-    is-fullwidth-code-point "^3.0.0"
-    strip-ansi "^6.0.1"
-
-"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
+"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
   version "4.2.3"
   resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
   integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -21973,7 +21987,7 @@ string_decoder@~1.1.1:
   dependencies:
     safe-buffer "~5.1.0"
 
-"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
+"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
   version "6.0.1"
   resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
   integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@@ -21987,13 +22001,6 @@ strip-ansi@5.2.0:
   dependencies:
     ansi-regex "^4.1.0"
 
-strip-ansi@^6.0.0, strip-ansi@^6.0.1:
-  version "6.0.1"
-  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
-  integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
-  dependencies:
-    ansi-regex "^5.0.1"
-
 strip-ansi@^7.0.1:
   version "7.1.0"
   resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45"
@@ -23797,7 +23804,7 @@ wordwrap@^1.0.0:
   resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb"
   integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==
 
-"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
+"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
   version "7.0.0"
   resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
   integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
@@ -23815,15 +23822,6 @@ wrap-ansi@^6.0.1:
     string-width "^4.1.0"
     strip-ansi "^6.0.0"
 
-wrap-ansi@^7.0.0:
-  version "7.0.0"
-  resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
-  integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
-  dependencies:
-    ansi-styles "^4.0.0"
-    string-width "^4.1.0"
-    strip-ansi "^6.0.0"
-
 wrap-ansi@^8.1.0:
   version "8.1.0"
   resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"