From 35466f47930a05955d20e4a4b60ad0e25bdc88a3 Mon Sep 17 00:00:00 2001 From: Eric Anderson Date: Tue, 16 Jul 2024 15:01:49 -0400 Subject: [PATCH] Confidential client --- docs/vite.md | 24 ++ examples-extra/todoapp/vite.config.mts | 9 + packages/client/src/createPlatformClient.ts | 5 +- packages/client/src/index.ts | 7 +- .../bin/testConfidentialClientNode.mjs | 4 + packages/e2e.sandbox.oauth/package.json | 55 ++++ packages/e2e.sandbox.oauth/src/index.ts | 17 ++ .../src/testConfidentialClientNode.ts | 56 +++++ packages/e2e.sandbox.oauth/tsconfig.cjs.json | 14 ++ packages/e2e.sandbox.oauth/tsconfig.json | 11 + packages/e2e.sandbox.oauth/tsup.config.js | 21 ++ packages/oauth/package.json | 1 - packages/oauth/src/PublicOauthClient.ts | 18 +- packages/oauth/src/common.ts | 237 ++++++++++++++++++ .../src/createConfidentialOauthClient.ts | 75 ++++++ packages/oauth/src/createPublicOauthClient.ts | 192 +++----------- packages/oauth/src/index.ts | 1 + pnpm-lock.yaml | 29 ++- 18 files changed, 598 insertions(+), 178 deletions(-) create mode 100644 docs/vite.md create mode 100755 packages/e2e.sandbox.oauth/bin/testConfidentialClientNode.mjs create mode 100644 packages/e2e.sandbox.oauth/package.json create mode 100644 packages/e2e.sandbox.oauth/src/index.ts create mode 100644 packages/e2e.sandbox.oauth/src/testConfidentialClientNode.ts create mode 100644 packages/e2e.sandbox.oauth/tsconfig.cjs.json create mode 100644 packages/e2e.sandbox.oauth/tsconfig.json create mode 100644 packages/e2e.sandbox.oauth/tsup.config.js create mode 100644 packages/oauth/src/common.ts create mode 100644 packages/oauth/src/createConfidentialOauthClient.ts 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/examples-extra/todoapp/vite.config.mts b/examples-extra/todoapp/vite.config.mts index 05f45701b..a1e11ed87 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 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 7c9b9947e..e066c0686 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 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..368b18b96 --- /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 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..74a560dd0 --- /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 000000000..f1da2b615 --- /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 a46994259..189566169 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 72fa1cff4..bccc0c79e 100644 --- a/packages/oauth/src/PublicOauthClient.ts +++ b/packages/oauth/src/PublicOauthClient.ts @@ -22,22 +22,32 @@ export type Events = { refresh: CustomEvent; }; -export interface PublicOauthClient { +export interface BaseOauthClient { (): Promise; signIn: () => Promise; - refresh: () => Promise; signOut: () => Promise; - addEventListener: ( + addEventListener: ( type: T, listener: ((evt: Events[T]) => void) | null, options?: boolean | AddEventListenerOptions, ) => void; - removeEventListener: ( + removeEventListener: ( type: T, callback: ((evt: Events[T]) => void) | null, options?: EventListenerOptions | boolean, ) => void; } + +export interface PublicOauthClient + extends BaseOauthClient<"signIn" | "signOut" | "refresh"> +{ + refresh: () => Promise; +} + +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 000000000..bb504a778 --- /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 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..9d6a6ea04 --- /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 bf73003f5..3ecfbda9a 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(); - 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 { + 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 { 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; - 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 fd49e01a0..c8e37fba5 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: