diff --git a/docs/vite.md b/docs/vite.md
new file mode 100644
index 0000000000..8254073dc7
--- /dev/null
+++ b/docs/vite.md
@@ -0,0 +1,24 @@
+# Using OSDK with Vite
+
+## Fixing common problems
+
+### "ReferenceError: process is not defined"
+
+The OSDK related libraries leverage a long standing convention to use `process.env.NODE_ENV` to determine the level of verbosity for `console.log`/`console.warn`/`console.error` and to create optimized production code.
+
+Recent versions of Vite have begun pushing developers to use `import.meta.env` instead of `process.env` which is a noble change with good intentions but one that creates problems for library authors trying to support multiple bundling frameworks.
+
+Out of the box, Vite will not perform the required replacement to optimize the code which leads to `process` being undefined and a runtime error for you. This can be worked around by updating your Vite config to process the replacement:
+
+```ts
+// ...
+export default defineConfig(({ mode }) => {
+  // ...
+  return {
+    define: {
+      "process.env.NODE_ENV": JSON.stringify(mode)
+    },
+    // ...
+  }
+}
+```
diff --git a/examples-extra/todoapp/vite.config.mts b/examples-extra/todoapp/vite.config.mts
index 05f45701bc..a1e11ed874 100644
--- a/examples-extra/todoapp/vite.config.mts
+++ b/examples-extra/todoapp/vite.config.mts
@@ -9,6 +9,9 @@ export default defineConfig(({ mode }) => {
   const env = loadEnv(mode, process.cwd(), "");
 
   return {
+    define: {
+      "process.env.NODE_ENV": JSON.stringify(mode),
+    },
     plugins: [
       react(),
       visualizer({
@@ -25,6 +28,12 @@ export default defineConfig(({ mode }) => {
         "/object-set-service": `${env.VITE_FOUNDRY_URL}`,
       },
     },
+    optimizeDeps: {
+      // shared.client is a mixed package that needs to be properly processed by vite
+      // but normally linked packages do not get that treatment so we have to explicitly add it here
+      // and in the `commonjsOptions` below
+      include: ["@osdk/client > @osdk/shared.client"],
+    },
     build: {
       outDir: "build/site/",
     },
diff --git a/packages/client/src/createPlatformClient.ts b/packages/client/src/createPlatformClient.ts
index 59edfca09b..beb162031a 100644
--- a/packages/client/src/createPlatformClient.ts
+++ b/packages/client/src/createPlatformClient.ts
@@ -14,9 +14,12 @@
  * limitations under the License.
  */
 
+import type { SharedClientContext } from "@osdk/shared.client";
 import { createSharedClientContext } from "@osdk/shared.client.impl";
 import { USER_AGENT } from "./util/UserAgent.js";
 
+export interface PlatformClient extends SharedClientContext {}
+
 /**
  * Creates a client that can only be used with Platform APIs.
  *
@@ -34,7 +37,7 @@ export function createPlatformClient(
   tokenProvider: () => Promise<string>,
   options: undefined = undefined,
   fetchFn: typeof globalThis.fetch = fetch,
-) {
+): PlatformClient {
   return createSharedClientContext(
     baseUrl,
     tokenProvider,
diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts
index 7c9b9947e9..e066c0686e 100644
--- a/packages/client/src/index.ts
+++ b/packages/client/src/index.ts
@@ -31,12 +31,11 @@ export type {
 } from "@osdk/client.api";
 export { PalantirApiError } from "@osdk/shared.net.errors";
 
+export { ActionValidationError } from "./actions/ActionValidationError.js";
 export type { Client } from "./Client.js";
+export { createAttachmentFromRid } from "./createAttachmentFromRid.js";
 export { createClient } from "./createClient.js";
 export { createPlatformClient } from "./createPlatformClient.js";
-
-export { createAttachmentFromRid } from "./createAttachmentFromRid.js";
-
-export { ActionValidationError } from "./actions/ActionValidationError.js";
+export type { PlatformClient } from "./createPlatformClient.js";
 export { isOk } from "./ResultOrError.js";
 export type { ResultOrError } from "./ResultOrError.js";
diff --git a/packages/e2e.sandbox.oauth/bin/testConfidentialClientNode.mjs b/packages/e2e.sandbox.oauth/bin/testConfidentialClientNode.mjs
new file mode 100755
index 0000000000..09295a6d5f
--- /dev/null
+++ b/packages/e2e.sandbox.oauth/bin/testConfidentialClientNode.mjs
@@ -0,0 +1,4 @@
+#!/usr/bin/env node
+import { testConfidentialClientNode } from "../build/esm/index.js";
+
+testConfidentialClientNode();
diff --git a/packages/e2e.sandbox.oauth/package.json b/packages/e2e.sandbox.oauth/package.json
new file mode 100644
index 0000000000..368b18b960
--- /dev/null
+++ b/packages/e2e.sandbox.oauth/package.json
@@ -0,0 +1,55 @@
+{
+  "name": "@osdk/e2e.sandbox.oauth",
+  "private": true,
+  "version": "0.0.0",
+  "license": "Apache-2.0",
+  "repository": {
+    "type": "git",
+    "url": "https://github.com/palantir/osdk-ts.git"
+  },
+  "exports": {
+    ".": {
+      "require": "./build/cjs/index.cjs",
+      "browser": "./build/browser/index.js",
+      "import": "./build/esm/index.js"
+    },
+    "./*": {
+      "require": "./build/cjs/public/*.cjs",
+      "browser": "./build/browser/public/*.js",
+      "import": "./build/esm/public/*.js"
+    }
+  },
+  "scripts": {
+    "check-attw": "../../scripts/build_common/check-attw.sh both",
+    "clean": "rm -rf lib dist types build tsconfig.tsbuildinfo",
+    "fix-lint": "eslint . --fix && dprint fmt --config $(find-up dprint.json)",
+    "lint": "eslint . && dprint check  --config $(find-up dprint.json)",
+    "transpile": "find . \\( -path build/cjs -or -path build/esm -or -path build/browser \\) -type f \\( -name '*.js' -or -name '*.js.map' -or -name '*.cjs' -or -name '*.cjs.map' \\) -delete && tsup",
+    "typecheck": "find . \\( -path build/cjs -or -path build/esm -or -path build/browser \\) -type f \\( -name '*.ts' -or -name '*.ts.map' -or -name '*.cts' -or -name '*.cts.map' \\) -delete && ../../scripts/build_common/typecheck.sh both"
+  },
+  "dependencies": {
+    "@osdk/client": "workspace:~",
+    "@osdk/oauth": "workspace:~",
+    "consola": "^3.2.3",
+    "tiny-invariant": "^1.3.3"
+  },
+  "devDependencies": {
+    "typescript": "^5.5.2"
+  },
+  "publishConfig": {
+    "access": "public"
+  },
+  "files": [
+    "build/cjs",
+    "build/esm",
+    "build/browser",
+    "CHANGELOG.md",
+    "package.json",
+    "templates",
+    "*.d.ts"
+  ],
+  "main": "./build/cjs/index.cjs",
+  "module": "./build/esm/index.js",
+  "types": "./build/cjs/index.d.cts",
+  "type": "module"
+}
diff --git a/packages/e2e.sandbox.oauth/src/index.ts b/packages/e2e.sandbox.oauth/src/index.ts
new file mode 100644
index 0000000000..a99acf91d4
--- /dev/null
+++ b/packages/e2e.sandbox.oauth/src/index.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2024 Palantir Technologies, Inc. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export { testConfidentialClientNode } from "./testConfidentialClientNode.js";
diff --git a/packages/e2e.sandbox.oauth/src/testConfidentialClientNode.ts b/packages/e2e.sandbox.oauth/src/testConfidentialClientNode.ts
new file mode 100644
index 0000000000..57e75fe112
--- /dev/null
+++ b/packages/e2e.sandbox.oauth/src/testConfidentialClientNode.ts
@@ -0,0 +1,56 @@
+/*
+ * Copyright 2024 Palantir Technologies, Inc. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { createConfidentialOauthClient } from "@osdk/oauth";
+import consola from "consola";
+import invariant from "tiny-invariant";
+
+declare const process: {
+  env: Record<string, string | undefined>;
+};
+
+export async function testConfidentialClientNode() {
+  const prefix = "TS_OSDK_E2E_OAUTH_CONFIDENTIAL_";
+  const FOUNDRY_CLIENT_ID = process.env[`${prefix}FOUNDRY_CLIENT_ID`];
+  const FOUNDRY_URL = process.env[`${prefix}FOUNDRY_URL`];
+  const FOUNDRY_CLIENT_SECRET = process.env[`${prefix}FOUNDRY_CLIENT_SECRET`];
+
+  invariant(
+    FOUNDRY_CLIENT_ID != null,
+    `${prefix}FOUNDRY_CLIENT_ID is required`,
+  );
+  invariant(
+    FOUNDRY_URL != null,
+    `${prefix}FOUNDRY_URL is required`,
+  );
+  invariant(
+    FOUNDRY_CLIENT_SECRET != null,
+    `${prefix}FOUNDRY_URL is required`,
+  );
+
+  const auth = createConfidentialOauthClient(
+    FOUNDRY_CLIENT_ID,
+    FOUNDRY_CLIENT_SECRET,
+    FOUNDRY_URL,
+  );
+
+  const token = await auth();
+  invariant(
+    token != null && token.length > 0,
+    "token should have been received",
+  );
+  consola.log(token);
+}
diff --git a/packages/e2e.sandbox.oauth/tsconfig.cjs.json b/packages/e2e.sandbox.oauth/tsconfig.cjs.json
new file mode 100644
index 0000000000..3e2ecf7d1c
--- /dev/null
+++ b/packages/e2e.sandbox.oauth/tsconfig.cjs.json
@@ -0,0 +1,14 @@
+{
+  "extends": "./tsconfig.json",
+  "compilerOptions": {
+    "module": "CommonJS",
+    "moduleResolution": "Node",
+    "target": "ES6",
+    "rootDir": "src",
+    "outDir": "build/cjs"
+  },
+  "include": [
+    "./src/**/*"
+  ],
+  "references": []
+}
diff --git a/packages/e2e.sandbox.oauth/tsconfig.json b/packages/e2e.sandbox.oauth/tsconfig.json
new file mode 100644
index 0000000000..74a560dd02
--- /dev/null
+++ b/packages/e2e.sandbox.oauth/tsconfig.json
@@ -0,0 +1,11 @@
+{
+  "extends": "../../monorepo/tsconfig/tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "build/esm"
+  },
+  "include": [
+    "./src/**/*"
+  ],
+  "references": []
+}
diff --git a/packages/e2e.sandbox.oauth/tsup.config.js b/packages/e2e.sandbox.oauth/tsup.config.js
new file mode 100644
index 0000000000..f1da2b615b
--- /dev/null
+++ b/packages/e2e.sandbox.oauth/tsup.config.js
@@ -0,0 +1,21 @@
+/*
+ * Copyright 2023 Palantir Technologies, Inc. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { defineConfig } from "tsup";
+
+export default defineConfig(async (options) =>
+  (await import("mytsup")).default(options, {})
+);
diff --git a/packages/oauth/package.json b/packages/oauth/package.json
index a46994259f..1895661699 100644
--- a/packages/oauth/package.json
+++ b/packages/oauth/package.json
@@ -32,7 +32,6 @@
   },
   "devDependencies": {
     "jest-extended": "^4.0.2",
-    "ts-expect": "^1.3.0",
     "typescript": "^5.5.2"
   },
   "publishConfig": {
diff --git a/packages/oauth/src/PublicOauthClient.ts b/packages/oauth/src/PublicOauthClient.ts
index 72fa1cff4a..bccc0c79e9 100644
--- a/packages/oauth/src/PublicOauthClient.ts
+++ b/packages/oauth/src/PublicOauthClient.ts
@@ -22,22 +22,32 @@ export type Events = {
   refresh: CustomEvent<Token>;
 };
 
-export interface PublicOauthClient {
+export interface BaseOauthClient<K extends keyof Events & string> {
   (): Promise<string>;
 
   signIn: () => Promise<Token>;
-  refresh: () => Promise<Token | undefined>;
   signOut: () => Promise<void>;
 
-  addEventListener: <T extends keyof Events & string>(
+  addEventListener: <T extends K>(
     type: T,
     listener: ((evt: Events[T]) => void) | null,
     options?: boolean | AddEventListenerOptions,
   ) => void;
 
-  removeEventListener: <T extends keyof Events & string>(
+  removeEventListener: <T extends K>(
     type: T,
     callback: ((evt: Events[T]) => void) | null,
     options?: EventListenerOptions | boolean,
   ) => void;
 }
+
+export interface PublicOauthClient
+  extends BaseOauthClient<"signIn" | "signOut" | "refresh">
+{
+  refresh: () => Promise<Token | undefined>;
+}
+
+export interface ConfidentialOauthClient
+  extends BaseOauthClient<"signIn" | "signOut">
+{
+}
diff --git a/packages/oauth/src/common.ts b/packages/oauth/src/common.ts
new file mode 100644
index 0000000000..bb504a778d
--- /dev/null
+++ b/packages/oauth/src/common.ts
@@ -0,0 +1,237 @@
+/*
+ * Copyright 2024 Palantir Technologies, Inc. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type {
+  AuthorizationServer,
+  Client,
+  HttpRequestOptions,
+  OAuth2TokenEndpointResponse,
+} from "oauth4webapi";
+import { processRevocationResponse, revocationRequest } from "oauth4webapi";
+import invariant from "tiny-invariant";
+import { TypedEventTarget } from "typescript-event-target";
+import type { BaseOauthClient, Events } from "./PublicOauthClient.js";
+import { throwIfError } from "./throwIfError.js";
+import type { Token } from "./Token.js";
+
+// Node 18 is supposed to have a `CustomEvent` but it is not exposed on `globalThis`
+// which creates a problem for making a single codebase for node and browser. This polyfill works around it
+const CustomEvent = process.env.target === "browser"
+  ? globalThis.CustomEvent
+  : globalThis.CustomEvent
+    ?? class CustomEvent<T> extends Event {
+      #detail: T | null;
+
+      constructor(type: string, options: EventInit & { detail: T }) {
+        super(type, options);
+        this.#detail = options?.detail ?? null;
+      }
+
+      get detail() {
+        return this.#detail;
+      }
+    };
+
+declare const process: {
+  env: Record<string, string | undefined>;
+};
+
+const localStorage = globalThis.localStorage;
+
+type LocalStorageState =
+  // when we are going to the login page
+  | {
+    refresh_token?: never;
+    codeVerifier?: never;
+    state?: never;
+    oldUrl: string;
+  }
+  // when we are redirecting to oauth login
+  | {
+    refresh_token?: never;
+    codeVerifier: string;
+    state: string;
+    oldUrl: string;
+  }
+  // when we have the refresh token
+  | {
+    refresh_token?: string;
+    codeVerifier?: never;
+    state?: never;
+    oldUrl?: never;
+  }
+  | {
+    refresh_token?: never;
+    codeVerifier?: never;
+    state?: never;
+    oldUrl?: never;
+  };
+
+export function saveLocal(client: Client, x: LocalStorageState) {
+  // MUST `localStorage?` as nodejs does not have localStorage
+  localStorage?.setItem(
+    `@osdk/oauth : refresh : ${client.client_id}`,
+    JSON.stringify(x),
+  );
+}
+
+export function removeLocal(client: Client) {
+  // MUST `localStorage?` as nodejs does not have localStorage
+  localStorage?.removeItem(`@osdk/oauth : refresh : ${client.client_id}`);
+}
+
+export function readLocal(client: Client): LocalStorageState {
+  return JSON.parse(
+    // MUST `localStorage?` as nodejs does not have localStorage
+    localStorage?.getItem(`@osdk/oauth : refresh : ${client.client_id}`)
+      ?? "{}",
+  );
+}
+
+export function common<
+  R extends undefined | (() => Promise<Token | undefined>),
+>(
+  client: Client,
+  as: AuthorizationServer,
+  _signIn: () => Promise<Token>,
+  oauthHttpOptions: HttpRequestOptions,
+  refresh: R,
+): {
+  getToken: BaseOauthClient<keyof Events & string> & { refresh: R };
+  makeTokenAndSaveRefresh: (
+    resp: OAuth2TokenEndpointResponse,
+    type: "signIn" | "refresh",
+  ) => Token;
+} {
+  let token: Token | undefined;
+  const eventTarget = new TypedEventTarget<Events>();
+
+  function makeTokenAndSaveRefresh(
+    resp: OAuth2TokenEndpointResponse,
+    type: "signIn" | "refresh",
+  ): Token {
+    const { refresh_token, expires_in, access_token } = resp;
+    invariant(expires_in != null);
+    saveLocal(client, { refresh_token });
+    token = {
+      refresh_token,
+      expires_in,
+      access_token,
+      expires_at: Date.now() + expires_in * 1000,
+    };
+
+    eventTarget.dispatchTypedEvent(
+      type,
+      new CustomEvent(
+        type,
+        { detail: token },
+      ),
+    );
+    return token;
+  }
+
+  let refreshTimeout: ReturnType<typeof setTimeout>;
+  function rmTimeout() {
+    if (refreshTimeout) clearTimeout(refreshTimeout);
+  }
+  function restartRefreshTimer(evt: CustomEvent<Token>) {
+    if (refresh) {
+      rmTimeout();
+      refreshTimeout = setTimeout(
+        refresh,
+        evt.detail.expires_in * 1000 - 60 * 1000,
+      );
+    }
+  }
+
+  async function signOut() {
+    invariant(token, "not signed in");
+
+    const result = await processRevocationResponse(
+      await revocationRequest(
+        as,
+        client,
+        token.access_token,
+        oauthHttpOptions,
+      ),
+    );
+
+    rmTimeout();
+
+    // Clean up
+    removeLocal(client);
+    token = undefined;
+    throwIfError(result);
+    eventTarget.dispatchTypedEvent("signOut", new Event("signOut"));
+  }
+
+  let pendingSignIn: Promise<Token> | undefined;
+  async function signIn() {
+    if (pendingSignIn) {
+      return pendingSignIn;
+    }
+    try {
+      pendingSignIn = _signIn();
+      return await pendingSignIn;
+    } finally {
+      pendingSignIn = undefined;
+    }
+  }
+
+  eventTarget.addEventListener("signIn", restartRefreshTimer);
+  eventTarget.addEventListener("refresh", restartRefreshTimer);
+
+  const getToken = Object.assign(async function getToken() {
+    if (!token || Date.now() >= token.expires_at) {
+      token = await signIn();
+    }
+    return token!.access_token;
+  }, {
+    signIn,
+    refresh,
+    signOut,
+    rmTimeout,
+    addEventListener: eventTarget.addEventListener.bind(
+      eventTarget,
+    ) as typeof eventTarget.addEventListener,
+    removeEventListener: eventTarget.removeEventListener.bind(
+      eventTarget,
+    ) as typeof eventTarget.removeEventListener,
+  });
+
+  return { getToken, makeTokenAndSaveRefresh };
+}
+
+export function createAuthorizationServer(
+  ctxPath: string,
+  url: string,
+): Required<
+  Pick<
+    AuthorizationServer,
+    | "issuer"
+    | "token_endpoint"
+    | "authorization_endpoint"
+    | "revocation_endpoint"
+  >
+> {
+  const issuer = `${new URL(ctxPath, url)}`;
+  return {
+    token_endpoint: `${issuer}/api/oauth2/token`,
+    authorization_endpoint: `${issuer}/api/oauth2/authorize`,
+    revocation_endpoint: `${issuer}/api/oauth2/revoke_token`,
+    issuer,
+  };
+}
diff --git a/packages/oauth/src/createConfidentialOauthClient.ts b/packages/oauth/src/createConfidentialOauthClient.ts
new file mode 100644
index 0000000000..9d6a6ea043
--- /dev/null
+++ b/packages/oauth/src/createConfidentialOauthClient.ts
@@ -0,0 +1,75 @@
+/*
+ * Copyright 2024 Palantir Technologies, Inc. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { Client, HttpRequestOptions } from "oauth4webapi";
+import {
+  clientCredentialsGrantRequest,
+  customFetch,
+  processClientCredentialsResponse,
+} from "oauth4webapi";
+import { common, createAuthorizationServer } from "./common.js";
+import type { ConfidentialOauthClient } from "./PublicOauthClient.js";
+import { throwIfError } from "./throwIfError.js";
+
+/**
+ * @param client_id
+ * @param client_secret
+ * @param url the base url of your foundry server
+ * @param scopes
+ * @param fetchFn
+ * @param ctxPath
+ * @returns which can be used as a token provider
+ */
+export function createConfidentialOauthClient(
+  client_id: string,
+  client_secret: string,
+  url: string,
+  scopes: string[] = ["api:read-data", "api:write-data"],
+  fetchFn: typeof globalThis.fetch = globalThis.fetch,
+  ctxPath: string = "/multipass",
+): ConfidentialOauthClient {
+  const client: Client = { client_id, client_secret };
+  const as = createAuthorizationServer(ctxPath, url);
+  const oauthHttpOptions: HttpRequestOptions = { [customFetch]: fetchFn };
+
+  const { getToken, makeTokenAndSaveRefresh } = common(
+    client,
+    as,
+    _signIn,
+    oauthHttpOptions,
+    undefined,
+  );
+
+  async function _signIn() {
+    return makeTokenAndSaveRefresh(
+      throwIfError(
+        await processClientCredentialsResponse(
+          as,
+          client,
+          await clientCredentialsGrantRequest(
+            as,
+            client,
+            new URLSearchParams({ scope: scopes.join(" ") }),
+            oauthHttpOptions,
+          ),
+        ),
+      ),
+      "signIn",
+    );
+  }
+
+  return getToken;
+}
diff --git a/packages/oauth/src/createPublicOauthClient.ts b/packages/oauth/src/createPublicOauthClient.ts
index bf73003f55..3ecfbda9aa 100644
--- a/packages/oauth/src/createPublicOauthClient.ts
+++ b/packages/oauth/src/createPublicOauthClient.ts
@@ -15,12 +15,7 @@
  */
 
 import delay from "delay";
-import type {
-  AuthorizationServer,
-  Client,
-  HttpRequestOptions,
-  OAuth2TokenEndpointResponse,
-} from "oauth4webapi";
+import type { Client, HttpRequestOptions } from "oauth4webapi";
 import {
   authorizationCodeGrantRequest,
   calculatePKCECodeChallenge,
@@ -28,64 +23,26 @@ import {
   generateRandomCodeVerifier,
   generateRandomState,
   processAuthorizationCodeOAuth2Response,
-  processRevocationResponse,
   refreshTokenGrantRequest,
-  revocationRequest,
   validateAuthResponse,
 } from "oauth4webapi";
-import invariant from "tiny-invariant";
-import { TypedEventTarget } from "typescript-event-target";
-import type { Events, PublicOauthClient } from "./PublicOauthClient.js";
+import {
+  common,
+  createAuthorizationServer,
+  readLocal,
+  removeLocal,
+  saveLocal,
+} from "./common.js";
+import type { PublicOauthClient } from "./PublicOauthClient.js";
 import { throwIfError } from "./throwIfError.js";
 import type { Token } from "./Token.js";
 
-const storageKey = "asdfasdfdhjlkajhgj";
-
 declare const process: undefined | {
   env?: {
     NODE_ENV: "production" | "development";
   };
 };
 
-type LocalStorageState =
-  // when we are going to the login page
-  | {
-    refresh_token?: never;
-    codeVerifier?: never;
-    state?: never;
-    oldUrl: string;
-  }
-  // when we are redirecting to oauth login
-  | {
-    refresh_token?: never;
-    codeVerifier: string;
-    state: string;
-    oldUrl: string;
-  }
-  // when we have the refresh token
-  | {
-    refresh_token?: string;
-    codeVerifier?: never;
-    state?: never;
-    oldUrl?: never;
-  }
-  | {
-    refresh_token?: never;
-    codeVerifier?: never;
-    state?: never;
-    oldUrl?: never;
-  };
-
-function saveLocal(x: LocalStorageState) {
-  localStorage.setItem(storageKey, JSON.stringify(x));
-}
-function removeLocal() {
-  localStorage.removeItem(storageKey);
-}
-function readLocal(): LocalStorageState {
-  return JSON.parse(localStorage.getItem(storageKey) ?? "{}");
-}
-
 /**
  * @param client_id
  * @param url the base url of your foundry server
@@ -109,19 +66,17 @@ export function createPublicOauthClient(
   fetchFn: typeof globalThis.fetch = globalThis.fetch,
   ctxPath: string = "/multipass",
 ): PublicOauthClient {
+  const client: Client = { client_id, token_endpoint_auth_method: "none" };
+  const as = createAuthorizationServer(ctxPath, url);
   const oauthHttpOptions: HttpRequestOptions = { [customFetch]: fetchFn };
-  const eventTarget = new TypedEventTarget<Events>();
 
-  const issuer = `${new URL(ctxPath, url)}`;
-
-  const as: AuthorizationServer = {
-    token_endpoint: `${issuer}/api/oauth2/token`,
-    authorization_endpoint: `${issuer}/api/oauth2/authorize`,
-    revocation_endpoint: `${issuer}/api/oauth2/revoke_token`,
-    issuer,
-  };
-
-  const client: Client = { client_id, token_endpoint_auth_method: "none" };
+  const { makeTokenAndSaveRefresh, getToken } = common(
+    client,
+    as,
+    _signIn,
+    oauthHttpOptions,
+    maybeRefresh.bind(globalThis, true),
+  );
 
   async function go(x: string) {
     if (useHistory) return window.history.replaceState({}, "", x);
@@ -131,29 +86,10 @@ export function createPublicOauthClient(
     throw new Error("Unable to redirect");
   }
 
-  function makeTokenAndSaveRefresh(
-    resp: OAuth2TokenEndpointResponse,
-    type: "signIn" | "refresh",
-  ): Token {
-    const { refresh_token, expires_in, access_token } = resp;
-    invariant(expires_in != null);
-    saveLocal({ refresh_token });
-    token = {
-      refresh_token,
-      expires_in,
-      access_token,
-      expires_at: Date.now() + expires_in * 1000,
-    };
-
-    eventTarget.dispatchTypedEvent(
-      type,
-      new CustomEvent(type, { detail: token }),
-    );
-    return token;
-  }
-
-  async function maybeRefresh(expectRefreshToken?: boolean) {
-    const { refresh_token } = readLocal();
+  async function maybeRefresh(
+    expectRefreshToken?: boolean,
+  ): Promise<Token | undefined> {
+    const { refresh_token } = readLocal(client);
     if (!refresh_token) {
       if (expectRefreshToken) throw new Error("No refresh token found");
       return;
@@ -185,7 +121,7 @@ export function createPublicOauthClient(
           e,
         );
       }
-      removeLocal();
+      removeLocal(client);
       if (expectRefreshToken) {
         throw new Error("Could not refresh token");
       }
@@ -193,7 +129,7 @@ export function createPublicOauthClient(
   }
 
   async function maybeHandleAuthReturn() {
-    const { codeVerifier, state, oldUrl } = readLocal();
+    const { codeVerifier, state, oldUrl } = readLocal(client);
     if (!codeVerifier) return;
 
     try {
@@ -232,20 +168,20 @@ export function createPublicOauthClient(
           e,
         );
       }
-      removeLocal();
+      removeLocal(client);
     }
   }
 
   async function initiateLoginRedirect(): Promise<void> {
     if (loginPage && window.location.href !== loginPage) {
-      saveLocal({ oldUrl: postLoginPage });
+      saveLocal(client, { oldUrl: postLoginPage });
       return await go(loginPage);
     }
 
     const state = generateRandomState()!;
     const codeVerifier = generateRandomCodeVerifier();
-    const oldUrl = readLocal().oldUrl ?? window.location.toString();
-    saveLocal({ codeVerifier, state, oldUrl });
+    const oldUrl = readLocal(client).oldUrl ?? window.location.toString();
+    saveLocal(client, { codeVerifier, state, oldUrl });
 
     window.location.assign(`${as
       .authorization_endpoint!}?${new URLSearchParams({
@@ -263,83 +199,15 @@ export function createPublicOauthClient(
     throw new Error("Unable to redirect");
   }
 
-  let refreshTimeout: ReturnType<typeof setTimeout>;
-  function rmTimeout() {
-    if (refreshTimeout) clearTimeout(refreshTimeout);
-  }
-  function restartRefreshTimer(evt: CustomEvent<Token>) {
-    rmTimeout();
-    refreshTimeout = setTimeout(
-      refresh,
-      evt.detail.expires_in * 1000 - 60 * 1000,
-    );
-  }
-
-  const refresh = maybeRefresh.bind(globalThis, true);
-
-  async function signOut() {
-    invariant(token, "not signed in");
-
-    const result = await processRevocationResponse(
-      await revocationRequest(
-        as,
-        client,
-        token.access_token,
-        oauthHttpOptions,
-      ),
-    );
-
-    rmTimeout();
-
-    // Clean up
-    removeLocal();
-    token = undefined;
-    throwIfError(result);
-    eventTarget.dispatchTypedEvent("signOut", new Event("signOut"));
-  }
-
-  let pendingSignIn: Promise<Token> | undefined;
-  async function signIn() {
-    if (pendingSignIn) {
-      return pendingSignIn;
-    }
-    try {
-      pendingSignIn = _signIn();
-      return await pendingSignIn;
-    } finally {
-      pendingSignIn = undefined;
-    }
-  }
   /** Will throw if there is no token! */
   async function _signIn() {
     // 1. Check if we have a refresh token in local storage
-    return token = await maybeRefresh()
+    return await maybeRefresh()
       // 2. If there is no refresh token we are likely trying to perform the callback
       ?? await maybeHandleAuthReturn()
       // 3. If we haven't been able to load the token from one of the two above ways, we need to make the initial auth request
       ?? await initiateLoginRedirect() as unknown as Token;
   }
 
-  eventTarget.addEventListener("signIn", restartRefreshTimer);
-  eventTarget.addEventListener("refresh", restartRefreshTimer);
-
-  let token: Token | undefined;
-  const ret = Object.assign(async function ret() {
-    if (!token || Date.now() >= token.expires_at) {
-      token = await signIn();
-    }
-    return token!.access_token;
-  }, {
-    signIn,
-    refresh,
-    signOut,
-    addEventListener: eventTarget.addEventListener.bind(
-      eventTarget,
-    ) as typeof eventTarget.addEventListener,
-    removeEventListener: eventTarget.removeEventListener.bind(
-      eventTarget,
-    ) as typeof eventTarget.removeEventListener,
-  });
-
-  return ret;
+  return getToken;
 }
diff --git a/packages/oauth/src/index.ts b/packages/oauth/src/index.ts
index 0cfab9691d..dee6054fd2 100644
--- a/packages/oauth/src/index.ts
+++ b/packages/oauth/src/index.ts
@@ -14,5 +14,6 @@
  * limitations under the License.
  */
 
+export { createConfidentialOauthClient } from "./createConfidentialOauthClient.js";
 export { createPublicOauthClient } from "./createPublicOauthClient.js";
 export type { PublicOauthClient } from "./PublicOauthClient.js";
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index fd49e01a02..c8e37fba5d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -931,6 +931,25 @@ importers:
         specifier: ^5.5.2
         version: 5.5.2
 
+  packages/e2e.sandbox.oauth:
+    dependencies:
+      '@osdk/client':
+        specifier: workspace:~
+        version: link:../client
+      '@osdk/oauth':
+        specifier: workspace:~
+        version: link:../oauth
+      consola:
+        specifier: ^3.2.3
+        version: 3.2.3
+      tiny-invariant:
+        specifier: ^1.3.3
+        version: 1.3.3
+    devDependencies:
+      typescript:
+        specifier: ^5.5.2
+        version: 5.5.3
+
   packages/example-generator:
     dependencies:
       '@osdk/create-app':
@@ -1429,9 +1448,6 @@ importers:
       jest-extended:
         specifier: ^4.0.2
         version: 4.0.2
-      ts-expect:
-        specifier: ^1.3.0
-        version: 1.3.0
       typescript:
         specifier: ^5.5.2
         version: 5.5.2
@@ -3094,7 +3110,7 @@ packages:
       '@inquirer/figures': 1.0.1
       '@inquirer/type': 1.3.1
       '@types/mute-stream': 0.0.4
-      '@types/node': 20.12.12
+      '@types/node': 20.14.10
       '@types/wrap-ansi': 3.0.0
       ansi-escapes: 4.3.2
       chalk: 4.1.2
@@ -4142,7 +4158,7 @@ packages:
   /@types/mute-stream@0.0.4:
     resolution: {integrity: sha512-CPM9nzrCPPJHQNA9keH9CVkVI+WR5kMa+7XEs5jcGQ0VoAGnLv242w8lIVgwAEfmE4oufJRaTc9PNLQl0ioAow==}
     dependencies:
-      '@types/node': 20.12.12
+      '@types/node': 20.14.10
 
   /@types/ngeohash@0.6.8:
     resolution: {integrity: sha512-A90x3HMwE1yXbWCnd0ztHzv8rAQPjwTzX2diYI/6OrWm/3oairDaehw5WPWJFgZ+8+J/OuF99IbipmMa2le6tQ==}
@@ -4159,6 +4175,7 @@ packages:
     resolution: {integrity: sha512-eWLDGF/FOSPtAvEqeRAQ4C8LSA7M1I7i0ky1I8U7kD1J5ITyW3AsRhQrKVoWf5pFKZ2kILsEGJhsI9r93PYnOw==}
     dependencies:
       undici-types: 5.26.5
+    dev: true
 
   /@types/node@20.14.10:
     resolution: {integrity: sha512-MdiXf+nDuMvY0gJKxyfZ7/6UFsETO7mGKF54MVD/ekJS6HdFtpZFBgrh6Pseu64XTb2MLyFPlbW6hj8HYRQNOQ==}
@@ -4193,7 +4210,7 @@ packages:
   /@types/readdir-glob@1.1.3:
     resolution: {integrity: sha512-trCChHpWDGCJCUPJRwD62eapW4KOru6h4S7n9KUIESaxhyBM/2Jh20P3XrFRQQ6Df78E/rq2DbUCVZlI8CXPnA==}
     dependencies:
-      '@types/node': 20.12.12
+      '@types/node': 20.14.10
     dev: true
 
   /@types/resolve@1.20.2: