diff --git a/.changeset/tame-rules-wash.md b/.changeset/tame-rules-wash.md new file mode 100644 index 000000000..c6c4860c5 --- /dev/null +++ b/.changeset/tame-rules-wash.md @@ -0,0 +1,5 @@ +--- +"@osdk/oauth": minor +--- + +Adds createConfidentialOauthClient diff --git a/docs/vite.md b/docs/vite.md new file mode 100644 index 000000000..8254073dc --- /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/packages/client/src/createPlatformClient.ts b/packages/client/src/createPlatformClient.ts index 59edfca09..beb162031 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, 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 9954610a3..29ba0670e 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -34,12 +34,11 @@ export type { export { isOk } 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 type { PlatformClient } from "./createPlatformClient.js"; export { createAttachmentUpload } from "./object/AttachmentUpload.js"; - -export { ActionValidationError } from "./actions/ActionValidationError.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 000000000..09295a6d5 --- /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 000000000..21c9668e5 --- /dev/null +++ b/packages/e2e.sandbox.oauth/package.json @@ -0,0 +1,59 @@ +{ + "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": "monorepo.tool.attw both", + "check-spelling": "cspell --quiet .", + "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": "monorepo.tool.transpile", + "typecheck": "monorepo.tool.typecheck both" + }, + "dependencies": { + "@osdk/client": "workspace:~", + "@osdk/oauth": "workspace:~", + "consola": "^3.2.3", + "tiny-invariant": "^1.3.3" + }, + "devDependencies": { + "@osdk/monorepo.api-extractor": "workspace:~", + "@osdk/monorepo.tsconfig": "workspace:~", + "@osdk/monorepo.tsup": "workspace:~", + "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 000000000..a99acf91d --- /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 000000000..57e75fe11 --- /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; +}; + +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 000000000..3e2ecf7d1 --- /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 000000000..6c1ec6f17 --- /dev/null +++ b/packages/e2e.sandbox.oauth/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@osdk/monorepo.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 000000000..7a77e08bc --- /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("@osdk/monorepo.tsup")).default(options, {}) +); diff --git a/packages/e2e.sandbox.todoapp/vite.config.mts b/packages/e2e.sandbox.todoapp/vite.config.mts index 05f45701b..a1e11ed87 100644 --- a/packages/e2e.sandbox.todoapp/vite.config.mts +++ b/packages/e2e.sandbox.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/oauth/package.json b/packages/oauth/package.json index 409e673ab..205187ffb 100644 --- a/packages/oauth/package.json +++ b/packages/oauth/package.json @@ -36,7 +36,6 @@ "@osdk/monorepo.tsconfig": "workspace:~", "@osdk/monorepo.tsup": "workspace:~", "jest-extended": "^4.0.2", - "ts-expect": "^1.3.0", "typescript": "^5.5.2" }, "publishConfig": { diff --git a/packages/oauth/src/BaseOauthClient.ts b/packages/oauth/src/BaseOauthClient.ts new file mode 100644 index 000000000..1f91140d3 --- /dev/null +++ b/packages/oauth/src/BaseOauthClient.ts @@ -0,0 +1,42 @@ +/* + * 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 { Token } from "./Token.js"; + +export type Events = { + signIn: CustomEvent; + signOut: Event; + refresh: CustomEvent; +}; + +export interface BaseOauthClient { + (): Promise; + + signIn: () => Promise; + signOut: () => Promise; + + addEventListener: ( + type: T, + listener: ((evt: Events[T]) => void) | null, + options?: boolean | AddEventListenerOptions, + ) => void; + + removeEventListener: ( + type: T, + callback: ((evt: Events[T]) => void) | null, + options?: EventListenerOptions | boolean, + ) => void; +} diff --git a/packages/oauth/src/ConfidentialOauthClient.ts b/packages/oauth/src/ConfidentialOauthClient.ts new file mode 100644 index 000000000..4672de2d0 --- /dev/null +++ b/packages/oauth/src/ConfidentialOauthClient.ts @@ -0,0 +1,22 @@ +/* + * 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 { BaseOauthClient } from "./BaseOauthClient.js"; + +export interface ConfidentialOauthClient + extends BaseOauthClient<"signIn" | "signOut"> +{ +} diff --git a/packages/oauth/src/PublicOauthClient.ts b/packages/oauth/src/PublicOauthClient.ts index 72fa1cff4..1c921b448 100644 --- a/packages/oauth/src/PublicOauthClient.ts +++ b/packages/oauth/src/PublicOauthClient.ts @@ -14,30 +14,11 @@ * limitations under the License. */ +import type { BaseOauthClient } from "./BaseOauthClient.js"; import type { Token } from "./Token.js"; -export type Events = { - signIn: CustomEvent; - signOut: Event; - refresh: CustomEvent; -}; - -export interface PublicOauthClient { - (): Promise; - - signIn: () => Promise; +export interface PublicOauthClient + extends BaseOauthClient<"signIn" | "signOut" | "refresh"> +{ refresh: () => Promise; - signOut: () => Promise; - - addEventListener: ( - type: T, - listener: ((evt: Events[T]) => void) | null, - options?: boolean | AddEventListenerOptions, - ) => void; - - removeEventListener: ( - type: T, - callback: ((evt: Events[T]) => void) | null, - options?: EventListenerOptions | boolean, - ) => void; } diff --git a/packages/oauth/src/common.ts b/packages/oauth/src/common.ts new file mode 100644 index 000000000..d5e9bbb4d --- /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 "./BaseOauthClient.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 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; +}; + +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), +>( + client: Client, + as: AuthorizationServer, + _signIn: () => Promise, + oauthHttpOptions: HttpRequestOptions, + refresh: R, +): { + getToken: BaseOauthClient & { refresh: R }; + makeTokenAndSaveRefresh: ( + resp: OAuth2TokenEndpointResponse, + type: "signIn" | "refresh", + ) => Token; +} { + let token: Token | undefined; + const eventTarget = new TypedEventTarget(); + + 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; + function rmTimeout() { + if (refreshTimeout) clearTimeout(refreshTimeout); + } + function restartRefreshTimer(evt: CustomEvent) { + 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 | 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 000000000..83196f218 --- /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 "./ConfidentialOauthClient.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 authServer = createAuthorizationServer(ctxPath, url); + const oauthHttpOptions: HttpRequestOptions = { [customFetch]: fetchFn }; + + const { getToken, makeTokenAndSaveRefresh } = common( + client, + authServer, + _signIn, + oauthHttpOptions, + undefined, + ); + + async function _signIn() { + return makeTokenAndSaveRefresh( + throwIfError( + await processClientCredentialsResponse( + authServer, + client, + await clientCredentialsGrantRequest( + authServer, + 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 5a41a8f15..2f55051f4 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 authServer = createAuthorizationServer(ctxPath, url); const oauthHttpOptions: HttpRequestOptions = { [customFetch]: fetchFn }; - const eventTarget = new TypedEventTarget(); - 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, + authServer, + _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 { + const { refresh_token } = readLocal(client); if (!refresh_token) { if (expectRefreshToken) throw new Error("No refresh token found"); return; @@ -165,10 +101,10 @@ export function createPublicOauthClient( return makeTokenAndSaveRefresh( throwIfError( await processAuthorizationCodeOAuth2Response( - as, + authServer, client, await refreshTokenGrantRequest( - as, + authServer, client, refresh_token, oauthHttpOptions, @@ -185,7 +121,7 @@ export function createPublicOauthClient( e, ); } - removeLocal(); + removeLocal(client); if (expectRefreshToken) { throw new Error("Could not refresh token"); } @@ -193,21 +129,21 @@ export function createPublicOauthClient( } async function maybeHandleAuthReturn() { - const { codeVerifier, state, oldUrl } = readLocal(); + const { codeVerifier, state, oldUrl } = readLocal(client); if (!codeVerifier) return; try { const ret = makeTokenAndSaveRefresh( throwIfError( await processAuthorizationCodeOAuth2Response( - as, + authServer, client, await authorizationCodeGrantRequest( - as, + authServer, client, throwIfError( validateAuthResponse( - as, + authServer, client, new URL(window.location.href), state, @@ -232,22 +168,22 @@ export function createPublicOauthClient( e, ); } - removeLocal(); + removeLocal(client); } } async function initiateLoginRedirect(): Promise { 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 + window.location.assign(`${authServer .authorization_endpoint!}?${new URLSearchParams({ client_id, response_type: "code", @@ -263,83 +199,15 @@ export function createPublicOauthClient( throw new Error("Unable to redirect"); } - let refreshTimeout: ReturnType; - function rmTimeout() { - if (refreshTimeout) clearTimeout(refreshTimeout); - } - function restartRefreshTimer(evt: CustomEvent) { - 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 | 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 0cfab9691..dee6054fd 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 ef969ebfe..c6b232866 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1282,6 +1282,34 @@ importers: specifier: ^5.5.2 version: 5.5.3 + 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: + '@osdk/monorepo.api-extractor': + specifier: workspace:~ + version: link:../monorepo.api-extractor + '@osdk/monorepo.tsconfig': + specifier: workspace:~ + version: link:../monorepo.tsconfig + '@osdk/monorepo.tsup': + specifier: workspace:~ + version: link:../monorepo.tsup + typescript: + specifier: ^5.5.2 + version: 5.5.3 + packages/e2e.sandbox.todoapp: dependencies: '@osdk/api': @@ -2130,9 +2158,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 @@ -8993,7 +9018,7 @@ snapshots: '@types/ws@8.5.10': dependencies: - '@types/node': 20.12.12 + '@types/node': 20.14.10 '@types/yargs-parser@21.0.2': {} @@ -10277,8 +10302,8 @@ snapshots: '@typescript-eslint/parser': 6.21.0(eslint@8.57.0)(typescript@5.5.3) eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.16.0(eslint@9.3.0)(typescript@5.5.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.16.0(eslint@9.3.0)(typescript@5.5.3))(eslint-plugin-import@2.29.1)(eslint@9.3.0))(eslint@8.57.0))(eslint@8.57.0) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) + eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.0) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.16.0(eslint@9.3.0)(typescript@5.5.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.16.0(eslint@9.3.0)(typescript@5.5.3))(eslint-plugin-import@2.29.1)(eslint@9.3.0))(eslint@8.57.0) eslint-plugin-jsx-a11y: 6.8.0(eslint@8.57.0) eslint-plugin-react: 7.34.1(eslint@8.57.0) eslint-plugin-react-hooks: 4.6.2(eslint@8.57.0) @@ -10296,13 +10321,13 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.16.0(eslint@9.3.0)(typescript@5.5.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.16.0(eslint@9.3.0)(typescript@5.5.3))(eslint-plugin-import@2.29.1)(eslint@9.3.0))(eslint@8.57.0))(eslint@8.57.0): + eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.0): dependencies: debug: 4.3.4 enhanced-resolve: 5.16.1 eslint: 8.57.0 - eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.16.0(eslint@9.3.0)(typescript@5.5.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.16.0(eslint@9.3.0)(typescript@5.5.3))(eslint-plugin-import@2.29.1)(eslint@9.3.0))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) + eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.16.0(eslint@9.3.0)(typescript@5.5.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.16.0(eslint@9.3.0)(typescript@5.5.3))(eslint-plugin-import@2.29.1)(eslint@9.3.0))(eslint@8.57.0) fast-glob: 3.3.2 get-tsconfig: 4.7.5 is-core-module: 2.13.1 @@ -10330,14 +10355,25 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-module-utils@2.8.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.16.0(eslint@9.3.0)(typescript@5.5.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.16.0(eslint@9.3.0)(typescript@5.5.3))(eslint-plugin-import@2.29.1)(eslint@9.3.0))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0): + eslint-module-utils@2.8.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 6.21.0(eslint@8.57.0)(typescript@5.5.3) eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.16.0(eslint@9.3.0)(typescript@5.5.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.16.0(eslint@9.3.0)(typescript@5.5.3))(eslint-plugin-import@2.29.1)(eslint@9.3.0))(eslint@8.57.0))(eslint@8.57.0) + eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.0) + transitivePeerDependencies: + - supports-color + + eslint-module-utils@2.8.1(@typescript-eslint/parser@7.16.0(eslint@9.3.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.16.0(eslint@9.3.0)(typescript@5.5.3))(eslint-plugin-import@2.29.1)(eslint@9.3.0))(eslint@8.57.0): + dependencies: + debug: 3.2.7 + optionalDependencies: + '@typescript-eslint/parser': 7.16.0(eslint@9.3.0)(typescript@5.5.3) + eslint: 8.57.0 + eslint-import-resolver-node: 0.3.9 + eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.16.0(eslint@9.3.0)(typescript@5.5.3))(eslint-plugin-import@2.29.1)(eslint@9.3.0) transitivePeerDependencies: - supports-color @@ -10366,7 +10402,7 @@ snapshots: dependencies: eslint: 9.3.0 - eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0): + eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.16.0(eslint@9.3.0)(typescript@5.5.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.16.0(eslint@9.3.0)(typescript@5.5.3))(eslint-plugin-import@2.29.1)(eslint@9.3.0))(eslint@8.57.0): dependencies: array-includes: 3.1.8 array.prototype.findlastindex: 1.2.5 @@ -10376,7 +10412,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.16.0(eslint@9.3.0)(typescript@5.5.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.16.0(eslint@9.3.0)(typescript@5.5.3))(eslint-plugin-import@2.29.1)(eslint@9.3.0))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0) + eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.16.0(eslint@9.3.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.16.0(eslint@9.3.0)(typescript@5.5.3))(eslint-plugin-import@2.29.1)(eslint@9.3.0))(eslint@8.57.0) hasown: 2.0.2 is-core-module: 2.13.1 is-glob: 4.0.3 @@ -10387,7 +10423,7 @@ snapshots: semver: 6.3.1 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 6.21.0(eslint@8.57.0)(typescript@5.5.3) + '@typescript-eslint/parser': 7.16.0(eslint@9.3.0)(typescript@5.5.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack