From 314a1ff55df3a8ac3cf29714a508a21130054049 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Sun, 20 Sep 2026 07:24:35 +0000 Subject: [PATCH 1/2] sdk: add OAuth callback selection and conditional credential cleanup --- .github/workflows/sdk-integration.yml | 1 + scripts/ci/check-sdk-package-consumers.py | 27 +- sdk/README.md | 51 +++ sdk/bun.lock | 18 + sdk/package.json | 4 +- sdk/src/lib/api.ts | 37 +- sdk/src/lib/index.ts | 6 + sdk/src/lib/main.tsx | 18 +- sdk/src/lib/platformApi.ts | 2 + .../test/integration/oauthRedirect.test.ts | 266 ++++++++++++ sdk/src/lib/test/oauthInitiation.test.ts | 165 +++++++ .../lib/test/userCredentialCleanup.test.ts | 409 ++++++++++++++++++ .../test/userCredentialCleanupReact.test.ts | 243 +++++++++++ sdk/src/lib/transportV2/auth.ts | 113 +++++ 14 files changed, 1339 insertions(+), 21 deletions(-) create mode 100644 sdk/src/lib/test/integration/oauthRedirect.test.ts create mode 100644 sdk/src/lib/test/oauthInitiation.test.ts create mode 100644 sdk/src/lib/test/userCredentialCleanup.test.ts create mode 100644 sdk/src/lib/test/userCredentialCleanupReact.test.ts diff --git a/.github/workflows/sdk-integration.yml b/.github/workflows/sdk-integration.yml index 4992bc0e0..beb7b533e 100644 --- a/.github/workflows/sdk-integration.yml +++ b/.github/workflows/sdk-integration.yml @@ -131,6 +131,7 @@ jobs: bun test src/lib/test/integration/apiKeys.test.ts --timeout 30000 bun test src/lib/test/integration/bip85.test.ts --timeout 30000 bun test src/lib/test/integration/developer.test.ts --timeout 30000 + bun test src/lib/test/integration/oauthRedirect.test.ts --timeout 30000 bun test src/lib/test/integration/signing.test.ts --timeout 30000 ' diff --git a/scripts/ci/check-sdk-package-consumers.py b/scripts/ci/check-sdk-package-consumers.py index 904bdafb2..47569c2fc 100755 --- a/scripts/ci/check-sdk-package-consumers.py +++ b/scripts/ci/check-sdk-package-consumers.py @@ -111,6 +111,7 @@ def check(sdk: Path, tarball: Path, consumer: Path) -> None: symbols = [ "OpenSecretProvider", "useOpenSecret", "createCustomFetch", "OpenSecretDeveloper", "useOpenSecretDeveloper", "OpenSecretInferenceCapacityError", + "captureUserCredentialSnapshot", "clearUserCredentialsIfCurrent", ] assertion = ( f"for (const name of {json.dumps(symbols)}) assert.equal(typeof sdk[name], 'function', name);\n" @@ -132,11 +133,23 @@ def check(sdk: Path, tarball: Path, consumer: Path) -> None: 'assert.equal(context.OpenSecretReact, undefined);\nconst sdk = context.MapleSDK;\n' + assertion ) types = ( - 'import { OpenSecretProvider, useOpenSecret, createCustomFetch } from "@mapleai/sdk";\n' - 'import type { Model, OpenSecretContextType, PcrEnvironment } from "@mapleai/sdk";\n' + 'import { OpenSecretProvider, useOpenSecret, createCustomFetch, ' + 'captureUserCredentialSnapshot, clearUserCredentialsIfCurrent } from "@mapleai/sdk";\n' + 'import type { Model, OpenSecretContextType, PcrEnvironment, UserCredentialSnapshot } ' + 'from "@mapleai/sdk";\n' 'const env: PcrEnvironment = "production";\n' 'const model: Model = { id: "example", created: 0, object: "model", owned_by: "example" };\n' 'type Context = OpenSecretContextType;\n' + 'declare const context: Context;\n' + 'for (const initiate of [context.initiateGitHubAuth, context.initiateGoogleAuth, ' + 'context.initiateAppleAuth]) {\n' + ' void initiate("");\n' + ' void initiate("invite", "https://auth.example.com/callback");\n' + '}\n' + 'const snapshot: UserCredentialSnapshot | null = ' + 'captureUserCredentialSnapshot("https://api.example.com");\n' + 'if (snapshot) { const cleared: boolean = clearUserCredentialsIfCurrent(snapshot); ' + 'void cleared; }\n' 'void [OpenSecretProvider, useOpenSecret, createCustomFetch, env, model];\n' ) for name in ("consumer.ts", "consumer.mts"): @@ -145,6 +158,16 @@ def check(sdk: Path, tarball: Path, consumer: Path) -> None: 'import sdk = require("@mapleai/sdk");\n' 'const env: sdk.PcrEnvironment = "production";\n' 'const model: sdk.Model = { id: "example", created: 0, object: "model", owned_by: "example" };\n' + 'declare const context: sdk.OpenSecretContextType;\n' + 'for (const initiate of [context.initiateGitHubAuth, context.initiateGoogleAuth, ' + 'context.initiateAppleAuth]) {\n' + ' void initiate("");\n' + ' void initiate("invite", "https://auth.example.com/callback");\n' + '}\n' + 'const snapshot: sdk.UserCredentialSnapshot | null = ' + 'sdk.captureUserCredentialSnapshot("https://api.example.com");\n' + 'if (snapshot) { const cleared: boolean = sdk.clearUserCredentialsIfCurrent(snapshot); ' + 'void cleared; }\n' 'void [sdk.OpenSecretProvider, sdk.useOpenSecret, env, model];\n' ) runtime_exports = [json.loads(run(["node", name], consumer)) for name in ( diff --git a/sdk/README.md b/sdk/README.md index d39ed8040..f97d2c206 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -140,6 +140,57 @@ conversations, inference, and account operations. Internal developer tooling uses `OpenSecretDeveloper` and `useOpenSecretDeveloper`; preserve that surface when changing the public exports. +### OAuth callback selection (4.1.0) + +The three browser initiation methods accept an optional final callback URL: + +```ts +const os = useOpenSecret(); +await os.initiateGoogleAuth(inviteCode); // Existing provider default. +await os.initiateGoogleAuth(inviteCode, `${window.location.origin}/auth/google/callback`); +await os.initiateGitHubAuth(inviteCode, `${window.location.origin}/auth/github/callback`); +await os.initiateAppleAuth(inviteCode, `${window.location.origin}/auth/apple/callback`); +``` + +The SDK forwards a supplied URL unchanged as `redirect_url`; an omitted +argument keeps the existing request shape. The backend validates exact +membership in that project's provider settings. Its platform settings types +also expose `additional_redirect_urls?: string[] | null`: when the provider +object is supplied, omission or `null` preserves the list and `[]` clears it. +See the [backend contract](../services/opensecret/docs/oauth-callbacks.md). + +Deploy backend callback-selection support and register the URL with the +provider before selecting a non-default callback. An older backend ignores +the new field and uses its default; the SDK does not silently retry with a +different callback. Native Apple sign-in and the Rust SDK are unchanged. + +### Conditional local user-credential cleanup (4.1.0) + +`captureUserCredentialSnapshot(apiUrl)` returns an opaque in-memory handle +for the currently persisted V2 user credentials, or `null` when none exist. +Capture it before awaiting the operation after which cleanup is needed. +`clearUserCredentialsIfCurrent(snapshot)` returns `true` only after persisting +the removal of that same credential pair and revision. It returns `false` for +an observed replacement, refresh, logout, or an already consumed handle. +Do not take a fresh snapshot merely to clear the replacement credentials. + +The handle exposes no tokens or account identity and cannot be copied, +serialized, or carried across a page reload or separate SDK instance. +Unavailable, unreadable, unsynchronized, malformed, or unwritable persistent +storage raises an error; a storage error leaves the handle retryable. These +operations never substitute or republish an in-memory fallback. A successful +clear invalidates the current SDK instance's React user state. It does not +call server logout, revoke tokens, or clear API keys, platform credentials, +other API origins, legacy global token slots, or the cache namespace root. + +**Concurrency limit:** [Web Storage](https://html.spec.whatwg.org/multipage/webstorage.html#introduction) +does not guarantee cross-tab locking. The API rejects changes observed before +its write, including a different token pair with a reused revision, but another context can still write between +the read and write. Applications requiring protection against simultaneous +writers must coordinate every mutation of the shared credential storage before +adopting this cleanup path. Locking only cleanup is insufficient. This API does +not claim that stronger cross-tab guarantee. + ### Development Use the pinned Nix shell and Bun version. `bun.lock` is the supported dependency diff --git a/sdk/bun.lock b/sdk/bun.lock index 7d41f24f9..7ade54522 100644 --- a/sdk/bun.lock +++ b/sdk/bun.lock @@ -20,12 +20,14 @@ "@noble/hashes": "1.8.0", "@types/bun": "1.1.13", "@types/react": "19.2.18", + "@types/react-test-renderer": "18.3.1", "@vitejs/plugin-react": "4.7.0", "ajv": "8.20.0", "eslint": "9.39.5", "eslint-plugin-react-hooks": "5.2.0", "globals": "15.15.0", "prettier": "3.9.6", + "react-test-renderer": "18.3.1", "typescript": "5.6.3", "typescript-eslint": "8.66.0", "vite": "6.4.3", @@ -308,8 +310,12 @@ "@types/node": ["@types/node@20.12.14", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-scnD59RpYD91xngrQQLGkE+6UrHUPzeKZWhhjBSa3HSkwjbQc38+q3RoIVEwxQGRw3M+j5hpNAM+lgV3cVormg=="], + "@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="], + "@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="], + "@types/react-test-renderer": ["@types/react-test-renderer@18.3.1", "", { "dependencies": { "@types/react": "^18" } }, "sha512-vAhnk0tG2eGa37lkU9+s5SoroCsRI08xnsWFiAXOuPH2jqzMbcXvKExXViPi1P5fIklDeCvXqyrdmipFaSkZrA=="], + "@types/ws": ["@types/ws@8.5.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw=="], "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.66.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.66.0", "@typescript-eslint/type-utils": "8.66.0", "@typescript-eslint/utils": "8.66.0", "@typescript-eslint/visitor-keys": "8.66.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.66.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A=="], @@ -548,6 +554,8 @@ "node-releases": ["node-releases@2.0.53", "", {}, "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + "openai": ["openai@5.23.2", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-MQBzmTulj+MM5O8SKEk/gL8a7s5mktS9zUtAkU257WjvobGc9nKcBuVwjyEEcb9SI8a8Y2G/mzn3vm9n1Jlleg=="], "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], @@ -590,8 +598,14 @@ "react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], + "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], + "react-shallow-renderer": ["react-shallow-renderer@16.15.0", "", { "dependencies": { "object-assign": "^4.1.1", "react-is": "^16.12.0 || ^17.0.0 || ^18.0.0" }, "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0" } }, "sha512-oScf2FqQ9LFVQgA73vr86xl2NaOIX73rh+YFqcOp68CWj56tSfgtGKrEbyhCj0rSijyG9M1CYprTh39fBi5hzA=="], + + "react-test-renderer": ["react-test-renderer@18.3.1", "", { "dependencies": { "react-is": "^18.3.1", "react-shallow-renderer": "^16.15.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-KkAgygexHUkQqtvvx/otwxtuFu5cVjfzTCtjXLH9boS19/Nbtg84zS7wIQn39G8IlrhThBpQsMKkq5ZHZIYFXA=="], + "reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], @@ -602,6 +616,8 @@ "rollup": ["rollup@4.62.4", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.62.4", "@rollup/rollup-android-arm64": "4.62.4", "@rollup/rollup-darwin-arm64": "4.62.4", "@rollup/rollup-darwin-x64": "4.62.4", "@rollup/rollup-freebsd-arm64": "4.62.4", "@rollup/rollup-freebsd-x64": "4.62.4", "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", "@rollup/rollup-linux-arm-musleabihf": "4.62.4", "@rollup/rollup-linux-arm64-gnu": "4.62.4", "@rollup/rollup-linux-arm64-musl": "4.62.4", "@rollup/rollup-linux-loong64-gnu": "4.62.4", "@rollup/rollup-linux-loong64-musl": "4.62.4", "@rollup/rollup-linux-ppc64-gnu": "4.62.4", "@rollup/rollup-linux-ppc64-musl": "4.62.4", "@rollup/rollup-linux-riscv64-gnu": "4.62.4", "@rollup/rollup-linux-riscv64-musl": "4.62.4", "@rollup/rollup-linux-s390x-gnu": "4.62.4", "@rollup/rollup-linux-x64-gnu": "4.62.4", "@rollup/rollup-linux-x64-musl": "4.62.4", "@rollup/rollup-openbsd-x64": "4.62.4", "@rollup/rollup-openharmony-arm64": "4.62.4", "@rollup/rollup-win32-arm64-msvc": "4.62.4", "@rollup/rollup-win32-ia32-msvc": "4.62.4", "@rollup/rollup-win32-x64-gnu": "4.62.4", "@rollup/rollup-win32-x64-msvc": "4.62.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg=="], + "scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], @@ -684,6 +700,8 @@ "@rushstack/ts-command-line/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + "@types/react-test-renderer/@types/react": ["@types/react@18.3.31", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw=="], + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.6", "", {}, "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw=="], "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], diff --git a/sdk/package.json b/sdk/package.json index 9e2740c90..3081680ee 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@mapleai/sdk", - "version": "4.0.1", + "version": "4.1.0", "packageManager": "bun@1.3.5", "license": "MIT", "homepage": "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/MaplePrivacyLabs/Maple/tree/master/sdk", @@ -57,12 +57,14 @@ "@noble/hashes": "1.8.0", "@types/bun": "1.1.13", "@types/react": "19.2.18", + "@types/react-test-renderer": "18.3.1", "@vitejs/plugin-react": "4.7.0", "ajv": "8.20.0", "eslint": "9.39.5", "eslint-plugin-react-hooks": "5.2.0", "globals": "15.15.0", "prettier": "3.9.6", + "react-test-renderer": "18.3.1", "typescript": "5.6.3", "typescript-eslint": "8.66.0", "vite": "6.4.3", diff --git a/sdk/src/lib/api.ts b/sdk/src/lib/api.ts index 438e0d530..b21088184 100644 --- a/sdk/src/lib/api.ts +++ b/sdk/src/lib/api.ts @@ -391,13 +391,19 @@ export async function changePassword(currentPassword: string, newPassword: strin export async function initiateGitHubAuth( client_id: string, - inviteCode?: string + inviteCode?: string, + redirectUrl?: string ): Promise { + const request = { + client_id, + ...(inviteCode ? { invite_code: inviteCode } : {}), + ...(redirectUrl !== undefined ? { redirect_url: redirectUrl } : {}) + }; try { - return await encryptedApiCall<{ invite_code?: string; client_id: string }, GithubAuthResponse>( + return await encryptedApiCall( `${apiUrl}/auth/github`, "POST", - inviteCode ? { invite_code: inviteCode, client_id } : { client_id }, + request, undefined, "Failed to initiate GitHub auth" ); @@ -570,13 +576,19 @@ export type AppleAuthResponse = { export async function initiateGoogleAuth( client_id: string, - inviteCode?: string + inviteCode?: string, + redirectUrl?: string ): Promise { + const request = { + client_id, + ...(inviteCode ? { invite_code: inviteCode } : {}), + ...(redirectUrl !== undefined ? { redirect_url: redirectUrl } : {}) + }; try { - return await encryptedApiCall<{ invite_code?: string; client_id: string }, GoogleAuthResponse>( + return await encryptedApiCall( `${apiUrl}/auth/google`, "POST", - inviteCode ? { invite_code: inviteCode, client_id } : { client_id }, + request, undefined, "Failed to initiate Google auth" ); @@ -637,6 +649,7 @@ export async function handleGoogleCallback( * Initiates Apple OAuth authentication flow * @param client_id - The client ID for your OpenSecret project * @param inviteCode - Optional invite code for new user registration + * @param redirectUrl - Optional exact backend-registered callback URL; omitted uses the project default * @returns A promise resolving to the Apple auth response containing auth URL and state * @description * This function starts the Apple OAuth authentication process by: @@ -649,13 +662,19 @@ export async function handleGoogleCallback( */ export async function initiateAppleAuth( client_id: string, - inviteCode?: string + inviteCode?: string, + redirectUrl?: string ): Promise { + const request = { + client_id, + ...(inviteCode ? { invite_code: inviteCode } : {}), + ...(redirectUrl !== undefined ? { redirect_url: redirectUrl } : {}) + }; try { - return await encryptedApiCall<{ invite_code?: string; client_id: string }, AppleAuthResponse>( + return await encryptedApiCall( `${apiUrl}/auth/apple`, "POST", - inviteCode ? { invite_code: inviteCode, client_id } : { client_id }, + request, undefined, "Failed to initiate Apple auth" ); diff --git a/sdk/src/lib/index.ts b/sdk/src/lib/index.ts index e92f002b2..64b3f3c3b 100644 --- a/sdk/src/lib/index.ts +++ b/sdk/src/lib/index.ts @@ -78,6 +78,12 @@ export { createApiKey, listApiKeys, deleteApiKey } from "./api"; export { mintNativeHandoffGrant } from "./api"; +export { + captureUserCredentialSnapshot, + clearUserCredentialsIfCurrent, + type UserCredentialSnapshot +} from "./transportV2/auth"; + export { prepareNativeOAuthHandoff, readNativeUserAuth, diff --git a/sdk/src/lib/main.tsx b/sdk/src/lib/main.tsx index d575497c2..e54e81d50 100644 --- a/sdk/src/lib/main.tsx +++ b/sdk/src/lib/main.tsx @@ -234,11 +234,11 @@ export type OpenSecretContextType = { * 4. After successful deletion, the client should clear all local storage and tokens */ confirmAccountDeletion: (confirmationCode: string, plaintextSecret: string) => Promise; - initiateGitHubAuth: (inviteCode: string) => Promise; + initiateGitHubAuth: (inviteCode: string, redirectUrl?: string) => Promise; handleGitHubCallback: (code: string, state: string, inviteCode: string) => Promise; - initiateGoogleAuth: (inviteCode: string) => Promise; + initiateGoogleAuth: (inviteCode: string, redirectUrl?: string) => Promise; handleGoogleCallback: (code: string, state: string, inviteCode: string) => Promise; - initiateAppleAuth: (inviteCode: string) => Promise; + initiateAppleAuth: (inviteCode: string, redirectUrl?: string) => Promise; handleAppleCallback: (code: string, state: string, inviteCode: string) => Promise; handleAppleNativeSignIn: (appleUser: api.AppleUser, inviteCode?: string) => Promise; mintNativeHandoffGrant: typeof api.mintNativeHandoffGrant; @@ -1254,9 +1254,9 @@ export function OpenSecretProvider({ } } - const initiateGitHubAuth = async (inviteCode: string) => { + const initiateGitHubAuth = async (inviteCode: string, redirectUrl?: string) => { try { - return await api.initiateGitHubAuth(clientId, inviteCode); + return await api.initiateGitHubAuth(clientId, inviteCode, redirectUrl); } catch (error) { console.error("Failed to initiate GitHub auth:", error); throw error; @@ -1275,9 +1275,9 @@ export function OpenSecretProvider({ } }; - const initiateGoogleAuth = async (inviteCode: string) => { + const initiateGoogleAuth = async (inviteCode: string, redirectUrl?: string) => { try { - return await api.initiateGoogleAuth(clientId, inviteCode); + return await api.initiateGoogleAuth(clientId, inviteCode, redirectUrl); } catch (error) { console.error("Failed to initiate Google auth:", error); throw error; @@ -1296,9 +1296,9 @@ export function OpenSecretProvider({ } }; - const initiateAppleAuth = async (inviteCode: string) => { + const initiateAppleAuth = async (inviteCode: string, redirectUrl?: string) => { try { - return await api.initiateAppleAuth(clientId, inviteCode); + return await api.initiateAppleAuth(clientId, inviteCode, redirectUrl); } catch (error) { console.error("Failed to initiate Apple auth:", error); throw error; diff --git a/sdk/src/lib/platformApi.ts b/sdk/src/lib/platformApi.ts index 227737f3a..14f434bd9 100644 --- a/sdk/src/lib/platformApi.ts +++ b/sdk/src/lib/platformApi.ts @@ -107,6 +107,8 @@ export type EmailSettings = { export type OAuthProviderSettings = { client_id: string; redirect_url: string; + /** Omitted or null preserves the registered list on update; [] clears it. */ + additional_redirect_urls?: string[] | null; team_id?: string; // Apple-specific: The Apple Developer Team ID key_id?: string; // Apple-specific: The Apple Developer Key ID for Sign in with Apple }; diff --git a/sdk/src/lib/test/integration/oauthRedirect.test.ts b/sdk/src/lib/test/integration/oauthRedirect.test.ts new file mode 100644 index 000000000..f03726031 --- /dev/null +++ b/sdk/src/lib/test/integration/oauthRedirect.test.ts @@ -0,0 +1,266 @@ +import { expect, test } from "bun:test"; +import { encode } from "@stablelib/base64"; +import { + getApiPcrConfig, + getApiUrl, + initiateGitHubAuth, + initiateGoogleAuth, + type GithubAuthResponse +} from "../../api"; +import { + createOrganization, + createProject, + createProjectSecret, + deleteOrganization, + deleteProject, + getOAuthSettings, + getPlatformApiUrl, + getPlatformPcrConfig, + platformLogin, + platformRegister, + setPlatformApiUrl, + updateOAuthSettings, + type OAuthProviderSettings, + type OAuthSettings +} from "../../platformApi"; +import { transportV2Runtime } from "../../transportV2/runtime"; + +const providers = [ + { + name: "github", + initiate: initiateGitHubAuth, + authorizationEndpoint: "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/login/oauth/authorize", + secretKey: "GITHUB_OAUTH_SECRET" + }, + { + name: "google", + initiate: initiateGoogleAuth, + authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth", + secretKey: "GOOGLE_OAUTH_SECRET" + } +] as const; + +type Provider = (typeof providers)[number]; +type AdditionalUrlsUpdate = "registered" | "omitted" | "null" | "empty"; + +function defaultRedirect(provider: Provider): string { + return `https://app.example.test/auth/${provider.name}/callback`; +} + +function additionalRedirect(provider: Provider): string { + return `https://auth.example.test/auth/${provider.name}/callback?channel=hosted`; +} + +function providerSettings( + provider: Provider, + additionalUrls: AdditionalUrlsUpdate +): OAuthProviderSettings { + return { + client_id: `sdk-fixture-${provider.name}`, + redirect_url: defaultRedirect(provider), + ...(additionalUrls === "omitted" + ? {} + : { + additional_redirect_urls: + additionalUrls === "null" + ? null + : additionalUrls === "empty" + ? [] + : [additionalRedirect(provider)] + }) + }; +} + +function oauthSettings(additionalUrls: AdditionalUrlsUpdate): OAuthSettings { + return { + github_oauth_enabled: true, + google_oauth_enabled: true, + apple_oauth_enabled: false, + github_oauth_settings: providerSettings(providers[0], additionalUrls), + google_oauth_settings: providerSettings(providers[1], additionalUrls) + }; +} + +function snapshotStorage(storage: Storage): [string, string][] { + const entries: [string, string][] = []; + for (let index = 0; index < storage.length; index += 1) { + const key = storage.key(index); + if (key !== null) { + const value = storage.getItem(key); + if (value !== null) entries.push([key, value]); + } + } + return entries; +} + +function restoreStorage(storage: Storage, entries: [string, string][]): void { + storage.clear(); + for (const [key, value] of entries) storage.setItem(key, value); +} + +async function loginFixtureDeveloper(): Promise { + const email = process.env.VITE_TEST_DEVELOPER_EMAIL; + const password = process.env.VITE_TEST_DEVELOPER_PASSWORD; + const name = process.env.VITE_TEST_DEVELOPER_NAME; + const inviteCode = process.env.VITE_TEST_DEVELOPER_INVITE_CODE; + if (!email || !password || !name || !inviteCode) { + throw new Error("Disposable developer credentials and invite code are required."); + } + + try { + await platformLogin(email, password); + } catch { + try { + await platformRegister(email, password, inviteCode, name); + } catch (error) { + if ( + error instanceof Error && + /Email already registered|User already exists/u.test(error.message) + ) { + await platformLogin(email, password); + } else { + throw error; + } + } + } +} + +async function fixtureStage(stage: string, operation: () => Promise): Promise { + try { + return await operation(); + } catch (error) { + // Labels contain only fixed fixture operation/provider names, never request data. + console.error(`OAuth redirect integration failed during ${stage}.`); + throw error; + } +} + +function expectAuthorization( + provider: Provider, + response: GithubAuthResponse, + redirectUrl: string +): void { + // Inspect the URL only. Following it or exchanging a code would contact a provider. + const authorization = new URL(response.auth_url); + expect(`${authorization.origin}${authorization.pathname}`).toBe(provider.authorizationEndpoint); + expect(authorization.searchParams.get("client_id")).toBe(`sdk-fixture-${provider.name}`); + expect(authorization.searchParams.get("redirect_uri")).toBe(redirectUrl); + expect(authorization.searchParams.get("response_type")).toBe("code"); + expect(authorization.searchParams.get("code_challenge_method")).toBe("S256"); + expect(authorization.searchParams.get("code_challenge")).toBeTruthy(); + // V2's opaque state retains the existing SDK csrf_token response property. + expect(response.csrf_token).toBeTruthy(); + expect(authorization.searchParams.get("state")).toBe(response.csrf_token); +} + +test("encrypted OAuth callback selection and settings preserve legacy defaults", async () => { + const apiUrl = getApiUrl(); + if ( + !["127.0.0.1", "localhost", "[::1]"].includes(new URL(apiUrl).hostname) || + getApiPcrConfig().environment !== "development" + ) { + throw new Error( + "OAuth redirect integration requires a disposable loopback development backend." + ); + } + const originalPlatformApiUrl = getPlatformApiUrl(); + const originalPlatformPcrConfig = getPlatformPcrConfig(); + const originalLocalStorage = snapshotStorage(localStorage); + const originalSessionStorage = snapshotStorage(sessionStorage); + + localStorage.clear(); + sessionStorage.clear(); + transportV2Runtime.clear(apiUrl); + setPlatformApiUrl(apiUrl, getApiPcrConfig()); + + try { + await fixtureStage("developer login", loginFixtureDeveloper); + const marker = crypto.randomUUID(); + const organization = await fixtureStage("organization creation", () => + createOrganization(`SDK OAuth ${marker}`) + ); + try { + // Both organization and project names must fit the backend's 50-character limit. + const project = await fixtureStage("project creation", () => + createProject(organization.id, `OAuth ${marker}`) + ); + try { + for (const provider of providers) { + await fixtureStage(`${provider.name} secret creation`, () => + createProjectSecret( + organization.id, + project.id, + provider.secretKey, + encode(new TextEncoder().encode(`dummy-${provider.name}-secret-${marker}`)) + ) + ); + } + await fixtureStage("initial OAuth settings", () => + updateOAuthSettings(organization.id, project.id, oauthSettings("registered")) + ); + + for (const provider of providers) { + // The original one-argument API and an explicit default select the same URL. + expectAuthorization( + provider, + await provider.initiate(project.client_id), + defaultRedirect(provider) + ); + for (const redirect of [defaultRedirect(provider), additionalRedirect(provider)]) { + expectAuthorization( + provider, + await provider.initiate(project.client_id, undefined, redirect), + redirect + ); + } + const otherProvider = providers.find((candidate) => candidate.name !== provider.name)!; + for (const unlisted of [ + "https://unlisted.example.test/callback", + additionalRedirect(otherProvider) + ]) { + await expect( + provider.initiate(project.client_id, undefined, unlisted) + ).rejects.toMatchObject({ status: 400 }); + } + } + + for (const update of ["omitted", "null", "empty"] as const) { + const expected = oauthSettings(update === "empty" ? "empty" : "registered"); + expect( + await fixtureStage(`${update} OAuth settings update`, () => + updateOAuthSettings(organization.id, project.id, oauthSettings(update)) + ) + ).toMatchObject(expected); + expect(await getOAuthSettings(organization.id, project.id)).toMatchObject(expected); + for (const provider of providers) { + if (update === "empty") { + await expect( + provider.initiate(project.client_id, undefined, additionalRedirect(provider)) + ).rejects.toMatchObject({ status: 400 }); + expectAuthorization( + provider, + await provider.initiate(project.client_id), + defaultRedirect(provider) + ); + } else { + expectAuthorization( + provider, + await provider.initiate(project.client_id, undefined, additionalRedirect(provider)), + additionalRedirect(provider) + ); + } + } + } + } finally { + await deleteProject(organization.id, project.id); + } + } finally { + await deleteOrganization(organization.id); + } + } finally { + transportV2Runtime.clear(apiUrl); + setPlatformApiUrl(originalPlatformApiUrl, originalPlatformPcrConfig); + restoreStorage(localStorage, originalLocalStorage); + restoreStorage(sessionStorage, originalSessionStorage); + } +}); diff --git a/sdk/src/lib/test/oauthInitiation.test.ts b/sdk/src/lib/test/oauthInitiation.test.ts new file mode 100644 index 000000000..5077c6e46 --- /dev/null +++ b/sdk/src/lib/test/oauthInitiation.test.ts @@ -0,0 +1,165 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { + getApiPcrConfig, + getApiUrl, + initiateAppleAuth, + initiateGitHubAuth, + initiateGoogleAuth, + setApiUrl +} from "../api"; +import type { PcrConfig } from "../pcr"; +import { + transportV2Runtime, + type TransportV2OAuthProvider, + type TransportV2RuntimeRequest +} from "../transportV2/runtime"; + +const API_URL = "https://oauth-initiation.example.test/backend"; +const CLIENT_ID = "00000000-0000-4000-8000-000000000001"; +const STATE = "opaque-backend-state-with-selected-callback"; +const SELECTED_REDIRECT = "https://AUTH.example.test:443/auth/%63allback?b=2&a=1"; + +type SeenRequest = { + apiUrl: string; + request: TransportV2RuntimeRequest["request"]; + body: unknown; +}; + +let seen: SeenRequest[]; +let continuations: Array<{ provider: TransportV2OAuthProvider; state: string }>; +let respond: () => Response; +let restoreRequest: () => void; +let previousApiUrl: string; +let previousPcrConfig: PcrConfig; + +function jsonResponse(value: unknown, init?: ResponseInit): Response { + return new Response(JSON.stringify(value), { + ...init, + headers: { "content-type": "application/json", ...init?.headers } + }); +} + +beforeEach(() => { + previousApiUrl = getApiUrl(); + previousPcrConfig = getApiPcrConfig(); + setApiUrl(API_URL, { environment: "development" }); + seen = []; + continuations = []; + respond = () => + jsonResponse({ auth_url: "https://provider.example.test/authorize", state: STATE }); + const request = spyOn(transportV2Runtime, "request").mockImplementation(async (input) => { + // The production encrypted API clears these bytes after the call completes. + // Capture a copy at its transport boundary rather than retaining that buffer. + const body = input.request.body ? new Uint8Array(input.request.body) : undefined; + seen.push({ + apiUrl: input.apiUrl, + request: { ...input.request, body }, + body: body ? JSON.parse(new TextDecoder().decode(body)) : undefined + }); + return { + response: respond(), + rememberOAuthContinuation(provider, state) { + continuations.push({ provider, state }); + } + }; + }); + restoreRequest = () => request.mockRestore(); +}); + +afterEach(() => { + restoreRequest(); + setApiUrl(previousApiUrl, previousPcrConfig); +}); + +for (const { provider, initiate } of [ + { provider: "github", initiate: initiateGitHubAuth }, + { provider: "google", initiate: initiateGoogleAuth }, + { provider: "apple", initiate: initiateAppleAuth } +] as const) { + describe(`${provider} OAuth initiation`, () => { + test("omits optional wire fields for existing callers and keeps the opaque continuation", async () => { + const result = await initiate(CLIENT_ID); + await initiate(CLIENT_ID, undefined, undefined); + await initiate(CLIENT_ID, ""); + + expect(seen.map(({ body }) => body)).toEqual([ + { client_id: CLIENT_ID }, + { client_id: CLIENT_ID }, + { client_id: CLIENT_ID } + ]); + expect(seen[0]).toMatchObject({ + apiUrl: API_URL, + request: { + method: "POST", + target: `/auth/${provider}`, + headers: [{ name: "content-type", value: "application/json" }] + } + }); + expect(seen[0].request.credential).toBeUndefined(); + expect(result).toEqual({ + auth_url: "https://provider.example.test/authorize", + ...(provider === "apple" ? { state: STATE } : { csrf_token: STATE }) + }); + expect(continuations).toEqual(Array(3).fill({ provider, state: STATE })); + }); + + test("preserves the invite-only request", async () => { + await initiate(CLIENT_ID, "existing-invite"); + expect(seen[0].body).toEqual({ client_id: CLIENT_ID, invite_code: "existing-invite" }); + }); + + test("sends the selected callback unchanged alongside the invite", async () => { + await initiate(CLIENT_ID, "existing-invite", SELECTED_REDIRECT); + expect(seen[0].body).toEqual({ + client_id: CLIENT_ID, + invite_code: "existing-invite", + redirect_url: SELECTED_REDIRECT + }); + }); + + test("accepts a callback selection without adding an invite", async () => { + await initiate(CLIENT_ID, undefined, SELECTED_REDIRECT); + expect(seen[0].body).toEqual({ client_id: CLIENT_ID, redirect_url: SELECTED_REDIRECT }); + }); + + test("leaves callback validation to the backend, including an explicit empty string", async () => { + await initiate(CLIENT_ID, undefined, ""); + expect(seen[0].body).toEqual({ client_id: CLIENT_ID, redirect_url: "" }); + }); + + test("preserves backend rejection status, message, and headers", async () => { + respond = () => + jsonResponse( + { error: "Callback selection rejected" }, + { status: 400, headers: { "x-test-error": "callback-rejected" } } + ); + const error = await initiate(CLIENT_ID, undefined, SELECTED_REDIRECT).catch( + (caught: unknown) => caught + ); + expect(error).toBeInstanceOf(Error); + expect(error).toMatchObject({ message: "Callback selection rejected", status: 400 }); + expect((error as Error & { headers: Headers }).headers.get("x-test-error")).toBe( + "callback-rejected" + ); + expect(continuations).toEqual([]); + }); + + test("retains the existing invalid-invite explanation", async () => { + respond = () => jsonResponse({ error: "Invalid invite code" }, { status: 400 }); + await expect(initiate(CLIENT_ID, "invalid-invite", SELECTED_REDIRECT)).rejects.toThrow( + "Invalid invite code. Please check and try again." + ); + expect(continuations).toEqual([]); + }); + + test("preserves transport failure messages", async () => { + respond = () => { + throw new Error("Transport failed before provider initiation"); + }; + await expect(initiate(CLIENT_ID, undefined, SELECTED_REDIRECT)).rejects.toThrow( + "Transport failed before provider initiation" + ); + expect(continuations).toEqual([]); + }); + }); +} diff --git a/sdk/src/lib/test/userCredentialCleanup.test.ts b/sdk/src/lib/test/userCredentialCleanup.test.ts new file mode 100644 index 000000000..75421bbed --- /dev/null +++ b/sdk/src/lib/test/userCredentialCleanup.test.ts @@ -0,0 +1,409 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { + captureUserCredentialSnapshot, + clearUserCredentialsIfCurrent, + type UserCredentialSnapshot +} from "../index"; +import { + TransportV2AuthorityChangedError, + getOrCreateTransportV2CacheRoot, + installTransportV2Credentials, + readTransportV2Credentials, + subscribeTransportV2AuthInvalidation, + type TransportV2AuthKind +} from "../transportV2/auth"; +import { TransportV2AuthRuntime } from "../transportV2/authRuntime"; +import type { TransportV2Runtime, TransportV2RuntimeRequest } from "../transportV2/runtime"; + +const STORAGE_PREFIX = "opensecret:transport-v2:auth:v1:"; +const AUDIENCE_PREFIX = "urn:opensecret:internal:transport-v2:"; + +class TestStorage implements Storage { + readonly values = new Map(); + readError = false; + writeError = false; + writes = 0; + + get length(): number { + return this.values.size; + } + + clear(): void { + this.values.clear(); + } + + getItem(key: string): string | null { + if (this.readError) throw new Error("test storage read denied"); + return this.values.get(key) ?? null; + } + + key(index: number): string | null { + return [...this.values.keys()][index] ?? null; + } + + removeItem(key: string): void { + this.values.delete(key); + } + + setItem(key: string, value: string): void { + this.writes += 1; + if (this.writeError) throw new Error("test storage write denied"); + this.values.set(key, value); + } +} + +let storage: TestStorage; +let apiUrl: string; +let otherApiUrl: string; +let testId = 0; +let originalStorage: PropertyDescriptor | undefined; +let originalFetch: PropertyDescriptor | undefined; +let fetchCalls = 0; +let unsubscribe: Array<() => void>; + +function restoreGlobal(name: string, descriptor: PropertyDescriptor | undefined): void { + if (descriptor) Object.defineProperty(globalThis, name, descriptor); + else Reflect.deleteProperty(globalThis, name); +} + +function exposeStorage(): void { + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + writable: true, + value: storage + }); +} + +function token( + kind: TransportV2AuthKind, + purpose: "access" | "refresh", + user: string, + marker: number +): string { + const claims = { + aud: `${AUDIENCE_PREFIX}${kind}:${purpose}-token`, + sub: user, + exp: 2_100_000_000 + marker, + ...(kind === "user" ? { tf: 2 } : {}), + marker + }; + return [ + Buffer.from(JSON.stringify({ alg: "ES256K", typ: "JWT" })).toString("base64url"), + Buffer.from(JSON.stringify(claims)).toString("base64url"), + Buffer.from(new Uint8Array(64).fill(marker)).toString("base64url") + ].join("."); +} + +function pair(user = "user-a", marker = 1, kind: TransportV2AuthKind = "user") { + return { + access: token(kind, "access", user, marker), + refresh: token(kind, "refresh", user, marker) + }; +} + +function install(user = "user-a", marker = 1, api = apiUrl, kind: TransportV2AuthKind = "user") { + const credentials = pair(user, marker, kind); + installTransportV2Credentials(api, kind, credentials.access, credentials.refresh); + return credentials; +} + +function key(api = apiUrl): string { + return `${STORAGE_PREFIX}${Buffer.from(new URL(api).origin).toString("base64url")}`; +} + +function captured(): UserCredentialSnapshot { + const snapshot = captureUserCredentialSnapshot(apiUrl); + expect(snapshot).not.toBeNull(); + if (!snapshot) throw new Error("test credentials were not captured"); + return snapshot; +} + +function listen(api: string, kind: TransportV2AuthKind, listener: () => void): void { + unsubscribe.push(subscribeTransportV2AuthInvalidation(api, kind, listener)); +} + +beforeEach(() => { + originalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); + originalFetch = Object.getOwnPropertyDescriptor(globalThis, "fetch"); + storage = new TestStorage(); + testId += 1; + apiUrl = `https://cleanup-${testId}.example.test/service`; + otherApiUrl = `https://other-cleanup-${testId}.example.test/service`; + unsubscribe = []; + fetchCalls = 0; + exposeStorage(); + Object.defineProperty(globalThis, "fetch", { + configurable: true, + writable: true, + value: async () => { + fetchCalls += 1; + throw new Error("credential cleanup must not make a network request"); + } + }); +}); + +afterEach(() => { + for (const stop of unsubscribe) stop(); + restoreGlobal("localStorage", originalStorage); + restoreGlobal("fetch", originalFetch); +}); + +describe("public conditional user credential cleanup", () => { + test("captures a frozen tokenless handle and clears only its current user slot", () => { + const credentials = install(); + install("platform-user", 2, apiUrl, "platform"); + install("other-user", 3, otherApiUrl); + const root = getOrCreateTransportV2CacheRoot(apiUrl); + const before = JSON.parse(storage.getItem(key())!); + const otherBefore = storage.getItem(key(otherApiUrl)); + for (const [name, value] of Object.entries({ + access_token: "legacy-access", + refresh_token: "legacy-refresh", + api_key: "unrelated-api-key" + })) + storage.setItem(name, value); + let userNotifications = 0; + let otherNotifications = 0; + listen(apiUrl, "user", () => { + userNotifications += 1; + }); + listen(apiUrl, "platform", () => { + otherNotifications += 1; + }); + listen(otherApiUrl, "user", () => { + otherNotifications += 1; + }); + + const snapshot = captured(); + expect(Object.isFrozen(snapshot)).toBe(true); + expect(Object.keys(snapshot)).toEqual([]); + expect(JSON.stringify(snapshot)).toBe("{}"); + expect(Object.values(snapshot)).not.toContain(credentials.access); + expect(Object.values(snapshot)).not.toContain(credentials.refresh); + const secondSnapshot = captured(); + expect(clearUserCredentialsIfCurrent(snapshot)).toBe(true); + const after = JSON.parse(storage.getItem(key())!); + expect(after.user).toEqual({ revision: before.user.revision + 1, credentials: null }); + expect(after.platform).toEqual(before.platform); + expect(after.cache_namespace_root).toBe(before.cache_namespace_root); + expect(getOrCreateTransportV2CacheRoot(apiUrl)).toEqual(root); + expect(storage.getItem(key(otherApiUrl))).toBe(otherBefore); + expect(storage.getItem("access_token")).toBe("legacy-access"); + expect(storage.getItem("refresh_token")).toBe("legacy-refresh"); + expect(storage.getItem("api_key")).toBe("unrelated-api-key"); + expect(readTransportV2Credentials(apiUrl, "user")).toBeNull(); + expect(clearUserCredentialsIfCurrent(snapshot)).toBe(false); + expect(clearUserCredentialsIfCurrent(secondSnapshot)).toBe(false); + expect(userNotifications).toBe(1); + expect(otherNotifications).toBe(0); + expect(fetchCalls).toBe(0); + }); + + test("returns null for no durable user credentials without creating storage", () => { + expect(captureUserCredentialSnapshot(apiUrl)).toBeNull(); + expect(storage.length).toBe(0); + install("platform-user", 2, apiUrl, "platform"); + const before = storage.getItem(key()); + const writes = storage.writes; + expect(captureUserCredentialSnapshot(apiUrl)).toBeNull(); + expect(storage.getItem(key())).toBe(before); + expect(storage.writes).toBe(writes); + }); + + for (const replacement of [ + { description: "account switch", user: "user-b", marker: 2 }, + { description: "same-user refresh", user: "user-a", marker: 2 }, + { description: "same-user re-login with identical tokens", user: "user-a", marker: 1 } + ]) { + test(`preserves credentials after ${replacement.description}`, () => { + install(); + const snapshot = captured(); + install(replacement.user, replacement.marker); + const before = storage.getItem(key()); + let notifications = 0; + listen(apiUrl, "user", () => { + notifications += 1; + }); + expect(clearUserCredentialsIfCurrent(snapshot)).toBe(false); + expect(storage.getItem(key())).toBe(before); + expect(notifications).toBe(0); + expect(clearUserCredentialsIfCurrent(captured())).toBe(true); + expect(notifications).toBe(1); + }); + } + + for (const changedToken of ["access_token", "refresh_token"] as const) { + test(`observes a sequential external ${changedToken} replacement even at the same revision`, () => { + install(); + const snapshot = captured(); + const original = storage.getItem(key())!; + const external = JSON.parse(original); + const replacement = pair("user-a", 7); + external.user.credentials[changedToken] = + changedToken === "access_token" ? replacement.access : replacement.refresh; + storage.setItem(key(), JSON.stringify(external)); + const before = storage.getItem(key()); + expect(clearUserCredentialsIfCurrent(snapshot)).toBe(false); + expect(storage.getItem(key())).toBe(before); + // A stale handle is consumed even if a later writer restores its old bytes. + storage.setItem(key(), original); + expect(clearUserCredentialsIfCurrent(snapshot)).toBe(false); + expect(storage.getItem(key())).toBe(original); + }); + } + + test("does not clear recreated storage with a reused revision and different credentials", () => { + install(); + const snapshot = captured(); + storage.removeItem(key()); + install("user-a", 8); + expect(JSON.parse(storage.getItem(key())!).user.revision).toBe(1); + const before = storage.getItem(key()); + expect(clearUserCredentialsIfCurrent(snapshot)).toBe(false); + expect(storage.getItem(key())).toBe(before); + expect(clearUserCredentialsIfCurrent(captured())).toBe(true); + }); + + test("returns false after durable removal without restoring its memory copy", () => { + install(); + const snapshot = captured(); + storage.removeItem(key()); + const writes = storage.writes; + expect(clearUserCredentialsIfCurrent(snapshot)).toBe(false); + expect(captureUserCredentialSnapshot(apiUrl)).toBeNull(); + expect(storage.getItem(key())).toBeNull(); + expect(storage.writes).toBe(writes); + }); + + test("rejects forged, copied and serialized handles without consuming the real one", () => { + install(); + const snapshot = captured(); + for (const forged of [{}, { ...snapshot }, JSON.parse(JSON.stringify(snapshot)), null]) { + expect(() => clearUserCredentialsIfCurrent(forged as UserCredentialSnapshot)).toThrow(); + } + expect(clearUserCredentialsIfCurrent(snapshot)).toBe(true); + }); + + for (const failure of ["missing", "getter", "read"] as const) { + test(`fails closed on ${failure} storage without notifying or consuming the handle`, () => { + install(); + const snapshot = captured(); + const before = storage.getItem(key()); + let notifications = 0; + listen(apiUrl, "user", () => { + notifications += 1; + }); + if (failure === "missing") Reflect.deleteProperty(globalThis, "localStorage"); + if (failure === "getter") { + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + get() { + throw new Error("test storage inaccessible"); + } + }); + } + if (failure === "read") storage.readError = true; + expect(() => captureUserCredentialSnapshot(apiUrl)).toThrow(); + expect(() => clearUserCredentialsIfCurrent(snapshot)).toThrow(); + expect(notifications).toBe(0); + storage.readError = false; + exposeStorage(); + expect(storage.getItem(key())).toBe(before); + expect(clearUserCredentialsIfCurrent(snapshot)).toBe(true); + expect(notifications).toBe(1); + }); + } + + test("keeps failed persistent writes retryable and never reports successful cleanup", () => { + install(); + const snapshot = captured(); + const before = storage.getItem(key()); + let notifications = 0; + listen(apiUrl, "user", () => { + notifications += 1; + }); + storage.writeError = true; + expect(() => clearUserCredentialsIfCurrent(snapshot)).toThrow("could not be persisted"); + expect(storage.getItem(key())).toBe(before); + expect(notifications).toBe(0); + storage.writeError = false; + expect(clearUserCredentialsIfCurrent(snapshot)).toBe(true); + expect(notifications).toBe(1); + }); + + test("rejects malformed durable state rather than clearing its last good memory copy", () => { + install(); + const snapshot = captured(); + const original = storage.getItem(key())!; + storage.setItem(key(), "{invalid"); + expect(() => captureUserCredentialSnapshot(apiUrl)).toThrow(); + expect(() => clearUserCredentialsIfCurrent(snapshot)).toThrow(); + expect(storage.getItem(key())).toBe("{invalid"); + storage.setItem(key(), original); + expect(clearUserCredentialsIfCurrent(snapshot)).toBe(true); + }); + + test("never captures or republishes a memory-only installation", () => { + storage.writeError = true; + install(); + expect(storage.getItem(key())).toBeNull(); + storage.writeError = false; + const writes = storage.writes; + expect(() => captureUserCredentialSnapshot(apiUrl)).toThrow("synchronized persistent storage"); + expect(storage.getItem(key())).toBeNull(); + expect(storage.writes).toBe(writes); + }); + + test("does not use a durable old snapshot while a newer installation exists only in memory", () => { + install(); + const snapshot = captured(); + const before = storage.getItem(key()); + storage.writeError = true; + install("user-b", 9); + storage.writeError = false; + const writes = storage.writes; + let notifications = 0; + listen(apiUrl, "user", () => { + notifications += 1; + }); + expect(() => captureUserCredentialSnapshot(apiUrl)).toThrow("synchronized persistent storage"); + expect(() => clearUserCredentialsIfCurrent(snapshot)).toThrow( + "synchronized persistent storage" + ); + expect(storage.getItem(key())).toBe(before); + expect(storage.writes).toBe(writes); + expect(notifications).toBe(0); + }); + + test("an already-pending refresh cannot reinstall credentials after successful cleanup", async () => { + install(); + const snapshot = captured(); + const refreshed = pair("user-a", 12); + let complete!: (response: Response) => void; + const pendingResponse = new Promise((resolve) => { + complete = resolve; + }); + let refreshRequests = 0; + const runtime = { + async request(input: TransportV2RuntimeRequest) { + input.beforeSend?.(); + expect(input.request.target).toBe("/refresh"); + refreshRequests += 1; + return { response: await pendingResponse, rememberOAuthContinuation() {} }; + } + } as unknown as TransportV2Runtime; + const auth = new TransportV2AuthRuntime({ runtime, nowUnixSeconds: () => 1_900_000_000 }); + const pendingRefresh = auth.refresh(apiUrl, { remoteAttestation: false }, "user"); + const outcome = pendingRefresh.then( + () => ({ error: undefined }), + (error: unknown) => ({ error }) + ); + expect(refreshRequests).toBe(1); + expect(clearUserCredentialsIfCurrent(snapshot)).toBe(true); + complete(Response.json({ access_token: refreshed.access, refresh_token: refreshed.refresh })); + expect((await outcome).error).toBeInstanceOf(TransportV2AuthorityChangedError); + expect(readTransportV2Credentials(apiUrl, "user")).toBeNull(); + expect(JSON.parse(storage.getItem(key())!).user.credentials).toBeNull(); + expect(fetchCalls).toBe(0); + }); +}); diff --git a/sdk/src/lib/test/userCredentialCleanupReact.test.ts b/sdk/src/lib/test/userCredentialCleanupReact.test.ts new file mode 100644 index 000000000..bd4e12f57 --- /dev/null +++ b/sdk/src/lib/test/userCredentialCleanupReact.test.ts @@ -0,0 +1,243 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { createElement } from "react"; +import TestRenderer, { act, type ReactTestRenderer } from "react-test-renderer"; +import { + captureUserCredentialSnapshot, + clearUserCredentialsIfCurrent, + OpenSecretProvider, + useOpenSecret, + type OpenSecretContextType, + type PcrConfig, + type UserResponse +} from "../index"; +import * as api from "../api"; +import { apiConfig } from "../apiConfig"; +import { + clearTransportV2Credentials, + installTransportV2Credentials, + readTransportV2Credentials +} from "../transportV2/auth"; +import { transportV2Runtime } from "../transportV2/runtime"; + +const API_URL = "https://react-user-cleanup.example.test/backend"; +const CLIENT_ID = "00000000-0000-4000-8000-000000000001"; +const API_KEY = "00000000-0000-4000-8000-000000000002"; +const PCR_CONFIG: PcrConfig = { environment: "development" }; + +let renderer: ReactTestRenderer | undefined; +let observed: OpenSecretContextType | undefined; +let respondWithProfile: (principalId: string) => Promise; +let requestedPrincipals: string[]; +let unexpectedTransportRequests: string[]; +let unexpectedNetworkRequests: number; +let restoreSpies: Array<() => void>; +let previousApiUrl: string; +let previousPcrConfig: PcrConfig; +let previousConfiguredAppUrl: string; +let previousConfiguredPlatformUrl: string; + +function token(principalId: string, purpose: "access" | "refresh", version: number): string { + const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url"); + return [ + encode({ alg: "ES256K", typ: "JWT" }), + encode({ + aud: `urn:opensecret:internal:transport-v2:user:${purpose}-token`, + sub: principalId, + tf: 2, + exp: 4_000_000_000 + version + }), + Buffer.from(new Uint8Array(64).fill(1)).toString("base64url") + ].join("."); +} + +function installUser(principalId: string, version = 0) { + return installTransportV2Credentials( + API_URL, + "user", + token(principalId, "access", version), + token(principalId, "refresh", version) + ); +} + +function profile(principalId: string, name = "Initial profile"): UserResponse { + return { + user: { + id: principalId, + name, + email: `${principalId}@example.test`, + email_verified: true, + login_method: "google", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z" + } + }; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((fulfill) => { + resolve = fulfill; + }); + return { promise, resolve }; +} + +function current(): OpenSecretContextType { + if (!observed) throw new Error("The provider consumer has not rendered."); + return observed; +} + +function Consumer() { + observed = useOpenSecret(); + return null; +} + +async function mountProvider(): Promise { + await act(async () => { + const element = createElement(OpenSecretProvider, { + apiUrl: API_URL, + clientId: CLIENT_ID, + pcrConfig: PCR_CONFIG, + children: createElement(Consumer) + }); + // The SDK uses React 19 types for its React 18/19 peer range; this renderer + // has React 18 types. Both runtime packages are pinned to React 18.3.1. + renderer = TestRenderer.create(element as Parameters[0]); + }); +} + +beforeEach(() => { + previousApiUrl = api.getApiUrl(); + previousPcrConfig = api.getApiPcrConfig(); + previousConfiguredAppUrl = apiConfig.appApiUrl; + previousConfiguredPlatformUrl = apiConfig.platformApiUrl; + clearTransportV2Credentials(API_URL); + observed = undefined; + requestedPrincipals = []; + unexpectedTransportRequests = []; + unexpectedNetworkRequests = 0; + respondWithProfile = async (principalId) => profile(principalId); + + // Keep the real provider, credential store, authority selection, and profile + // publication fence. A successful deferred API result must be discarded by + // the provider itself, independently of lower-level response guards. + const fetchProfile = spyOn(api, "fetchUserWithTransportV2Authority").mockImplementation( + async (_apiUrl, _pcrConfig, authority) => { + const principalId = authority.credentials.principalId; + requestedPrincipals.push(principalId); + return respondWithProfile(principalId); + } + ); + const request = spyOn(transportV2Runtime, "request").mockImplementation(async (input) => { + unexpectedTransportRequests.push(input.request.target); + throw new Error("This provider cleanup test must not make a transport request."); + }); + const fetch = spyOn(globalThis, "fetch").mockImplementation(async () => { + unexpectedNetworkRequests += 1; + throw new Error("This provider cleanup test must not access the network."); + }); + restoreSpies = [ + () => fetchProfile.mockRestore(), + () => request.mockRestore(), + () => fetch.mockRestore() + ]; +}); + +afterEach(async () => { + try { + await act(async () => { + renderer?.unmount(); + }); + } finally { + renderer = undefined; + observed = undefined; + for (const restore of restoreSpies) restore(); + clearTransportV2Credentials(API_URL); + api.setApiUrl(previousApiUrl, previousPcrConfig); + apiConfig.configure(previousConfiguredAppUrl, previousConfiguredPlatformUrl); + } + expect(unexpectedTransportRequests).toEqual([]); + expect(unexpectedNetworkRequests).toBe(0); +}); + +describe("public user credential cleanup through the React provider", () => { + test("clears the published user while preserving the separately configured API key", async () => { + installUser("first-user"); + await mountProvider(); + expect(current().auth).toEqual({ loading: false, user: profile("first-user") }); + const snapshot = captureUserCredentialSnapshot(API_URL); + expect(snapshot).not.toBeNull(); + + await act(async () => { + current().setApiKey(API_KEY); + }); + expect(current().apiKey).toBe(API_KEY); + + await act(async () => { + expect(clearUserCredentialsIfCurrent(snapshot!)).toBe(true); + }); + expect(current().auth).toEqual({ loading: false, user: undefined }); + expect(current().apiKey).toBe(API_KEY); + expect(readTransportV2Credentials(API_URL, "user")).toBeNull(); + expect(requestedPrincipals).toEqual(["first-user"]); + }); + + test("a deferred successful profile result cannot republish the user after cleanup", async () => { + installUser("first-user"); + await mountProvider(); + expect(current().auth.user).toEqual(profile("first-user")); + const snapshot = captureUserCredentialSnapshot(API_URL); + expect(snapshot).not.toBeNull(); + + const started = deferred(); + const pendingProfile = deferred(); + respondWithProfile = () => { + started.resolve(); + return pendingProfile.promise; + }; + let refetch!: Promise; + await act(async () => { + refetch = current().refetchUser(); + await started.promise; + }); + expect(requestedPrincipals).toEqual(["first-user", "first-user"]); + + await act(async () => { + expect(clearUserCredentialsIfCurrent(snapshot!)).toBe(true); + }); + expect(current().auth).toEqual({ loading: false, user: undefined }); + + await act(async () => { + pendingProfile.resolve(profile("first-user", "Deferred stale profile")); + await refetch; + }); + expect(current().auth).toEqual({ loading: false, user: undefined }); + expect(readTransportV2Credentials(API_URL, "user")).toBeNull(); + expect(requestedPrincipals).toEqual(["first-user", "first-user"]); + }); + + for (const replacementId of ["first-user", "second-user"]) { + test(`stale cleanup preserves the newer ${replacementId === "first-user" ? "same-account" : "other-account"} profile`, async () => { + installUser("first-user"); + await mountProvider(); + expect(current().auth.user).toEqual(profile("first-user")); + const snapshot = captureUserCredentialSnapshot(API_URL); + expect(snapshot).not.toBeNull(); + + respondWithProfile = async (principalId) => profile(principalId, "Newer login profile"); + let installed!: ReturnType; + await act(async () => { + installed = installUser(replacementId, 1); + await current().refetchUser(); + }); + const newerProfile = profile(replacementId, "Newer login profile"); + expect(current().auth).toEqual({ loading: false, user: newerProfile }); + + await act(async () => { + expect(clearUserCredentialsIfCurrent(snapshot!)).toBe(false); + }); + expect(current().auth).toEqual({ loading: false, user: newerProfile }); + expect(readTransportV2Credentials(API_URL, "user")).toEqual(installed); + expect(requestedPrincipals).toEqual(["first-user", replacementId]); + }); + } +}); diff --git a/sdk/src/lib/transportV2/auth.ts b/sdk/src/lib/transportV2/auth.ts index dffff99b8..46553dda2 100644 --- a/sdk/src/lib/transportV2/auth.ts +++ b/sdk/src/lib/transportV2/auth.ts @@ -32,6 +32,27 @@ export interface TransportV2AuthSnapshot { revision: number; } +const userCredentialSnapshotBrand = Symbol("UserCredentialSnapshot"); + +/** + * An opaque, process-local handle for conditional user-credential cleanup. + * Keep the original object in memory; copying or serializing it is unsupported. + * The handle exposes no tokens, account identity, or storage keys. + */ +export interface UserCredentialSnapshot { + readonly [userCredentialSnapshotBrand]: true; +} + +interface CapturedUserCredentials { + apiOrigin: string; + revision: number; + accessToken: string; + refreshToken: string; +} + +const issuedUserCredentialSnapshots = new WeakSet(); +const capturedUserCredentials = new WeakMap(); + export type TransportV2ProfilePublicationDecision = "publish" | "reload" | "discard"; export class TransportV2AuthorityChangedError extends Error { @@ -651,6 +672,98 @@ export function clearTransportV2CredentialsIfCurrent(expected: TransportV2AuthSn return true; } +function readPersistedStateForUserCleanup(apiOrigin: string): { + state: PersistedState; + key: string; + storage: Storage; +} { + const persistent = persistentStorageResult(); + if (persistent.kind !== "available") { + throw new Error("User credential cleanup requires accessible persistent storage."); + } + const key = storageKey(apiOrigin); + // Reading via readBlob could substitute or republish a stale memory copy. + // A caller must resolve an unpersisted write before capturing or clearing. + if (fallbackOnlyKeys.has(key) || pendingRemovalKeys.has(key)) { + throw new Error("User credential cleanup requires synchronized persistent storage."); + } + let raw: string | null; + try { + raw = persistent.storage.getItem(key); + } catch { + throw new Error("User credential cleanup could not read persistent storage."); + } + return { + state: raw === null ? emptyState(apiOrigin) : parseState(raw, apiOrigin), + key, + storage: persistent.storage + }; +} + +/** + * Capture the currently persisted V2 user credentials for this API origin. + * Returns null when there are none; throws on unavailable, unsynchronized, or + * malformed storage. Capture before the asynchronous work that needs cleanup. + * This does not copy credentials into the returned handle or perform network I/O. + */ +export function captureUserCredentialSnapshot(apiUrl: string): UserCredentialSnapshot | null { + const apiOrigin = canonicalizeTransportV2ApiOrigin(apiUrl); + const { state } = readPersistedStateForUserCleanup(apiOrigin); + const credentials = credentialsFromSlot(apiOrigin, "user", state.user); + if (!credentials) return null; + const snapshot: UserCredentialSnapshot = Object.freeze({ + [userCredentialSnapshotBrand]: true as const + }); + issuedUserCredentialSnapshots.add(snapshot); + capturedUserCredentials.set(snapshot, { + apiOrigin, + revision: credentials.revision, + accessToken: credentials.accessToken, + refreshToken: credentials.refreshToken + }); + return snapshot; +} + +/** + * Clear the captured V2 user credentials only if the persisted pair and revision + * still match. Returns false for an observed replacement or a consumed handle. + * Storage errors throw and leave the handle retryable; invalid handles throw. + * Success invalidates the local provider's user state, but does not revoke a + * server session or clear API keys, platform credentials, or the cache root. + * + * localStorage has no atomic compare-and-swap across browser contexts. This + * fences changes observed before the write, not a simultaneous write by another + * tab. Callers requiring that stronger guarantee must coordinate all writers. + * Never recapture on a stale result merely to force cleanup of newer credentials. + */ +export function clearUserCredentialsIfCurrent(snapshot: UserCredentialSnapshot): boolean { + if (!issuedUserCredentialSnapshots.has(snapshot)) { + throw new TypeError("Expected an original user credential snapshot from this SDK instance."); + } + const expected = capturedUserCredentials.get(snapshot); + if (!expected) return false; + const { state, key, storage } = readPersistedStateForUserCleanup(expected.apiOrigin); + if ( + state.user.revision !== expected.revision || + state.user.credentials?.access_token !== expected.accessToken || + state.user.credentials?.refresh_token !== expected.refreshToken + ) { + capturedUserCredentials.delete(snapshot); + return false; + } + state.user = { revision: nextRevision(state.user.revision), credentials: null }; + const encoded = JSON.stringify(state); + try { + storage.setItem(key, encoded); + } catch { + throw new Error("User credential cleanup could not be persisted."); + } + memoryBlobs.set(key, encoded); + capturedUserCredentials.delete(snapshot); + notifyInvalidated(expected.apiOrigin, "user"); + return true; +} + export function clearTransportV2Credentials(apiUrl: string, kind?: TransportV2AuthKind): void { const apiOrigin = canonicalizeTransportV2ApiOrigin(apiUrl); let state: PersistedState; From 89c91b594618de470ae2383f32264a79f63594ca Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Mon, 21 Sep 2026 07:50:11 +0000 Subject: [PATCH 2/2] sdk: keep auth migration focused on callback selection --- scripts/ci/check-sdk-package-consumers.py | 15 +- sdk/README.md | 27 -- sdk/bun.lock | 18 - sdk/package.json | 2 - sdk/src/lib/index.ts | 6 - .../lib/test/userCredentialCleanup.test.ts | 409 ------------------ .../test/userCredentialCleanupReact.test.ts | 243 ----------- sdk/src/lib/transportV2/auth.ts | 113 ----- 8 files changed, 2 insertions(+), 831 deletions(-) delete mode 100644 sdk/src/lib/test/userCredentialCleanup.test.ts delete mode 100644 sdk/src/lib/test/userCredentialCleanupReact.test.ts diff --git a/scripts/ci/check-sdk-package-consumers.py b/scripts/ci/check-sdk-package-consumers.py index 47569c2fc..c8a89b916 100755 --- a/scripts/ci/check-sdk-package-consumers.py +++ b/scripts/ci/check-sdk-package-consumers.py @@ -111,7 +111,6 @@ def check(sdk: Path, tarball: Path, consumer: Path) -> None: symbols = [ "OpenSecretProvider", "useOpenSecret", "createCustomFetch", "OpenSecretDeveloper", "useOpenSecretDeveloper", "OpenSecretInferenceCapacityError", - "captureUserCredentialSnapshot", "clearUserCredentialsIfCurrent", ] assertion = ( f"for (const name of {json.dumps(symbols)}) assert.equal(typeof sdk[name], 'function', name);\n" @@ -133,10 +132,8 @@ def check(sdk: Path, tarball: Path, consumer: Path) -> None: 'assert.equal(context.OpenSecretReact, undefined);\nconst sdk = context.MapleSDK;\n' + assertion ) types = ( - 'import { OpenSecretProvider, useOpenSecret, createCustomFetch, ' - 'captureUserCredentialSnapshot, clearUserCredentialsIfCurrent } from "@mapleai/sdk";\n' - 'import type { Model, OpenSecretContextType, PcrEnvironment, UserCredentialSnapshot } ' - 'from "@mapleai/sdk";\n' + 'import { OpenSecretProvider, useOpenSecret, createCustomFetch } from "@mapleai/sdk";\n' + 'import type { Model, OpenSecretContextType, PcrEnvironment } from "@mapleai/sdk";\n' 'const env: PcrEnvironment = "production";\n' 'const model: Model = { id: "example", created: 0, object: "model", owned_by: "example" };\n' 'type Context = OpenSecretContextType;\n' @@ -146,10 +143,6 @@ def check(sdk: Path, tarball: Path, consumer: Path) -> None: ' void initiate("");\n' ' void initiate("invite", "https://auth.example.com/callback");\n' '}\n' - 'const snapshot: UserCredentialSnapshot | null = ' - 'captureUserCredentialSnapshot("https://api.example.com");\n' - 'if (snapshot) { const cleared: boolean = clearUserCredentialsIfCurrent(snapshot); ' - 'void cleared; }\n' 'void [OpenSecretProvider, useOpenSecret, createCustomFetch, env, model];\n' ) for name in ("consumer.ts", "consumer.mts"): @@ -164,10 +157,6 @@ def check(sdk: Path, tarball: Path, consumer: Path) -> None: ' void initiate("");\n' ' void initiate("invite", "https://auth.example.com/callback");\n' '}\n' - 'const snapshot: sdk.UserCredentialSnapshot | null = ' - 'sdk.captureUserCredentialSnapshot("https://api.example.com");\n' - 'if (snapshot) { const cleared: boolean = sdk.clearUserCredentialsIfCurrent(snapshot); ' - 'void cleared; }\n' 'void [sdk.OpenSecretProvider, sdk.useOpenSecret, env, model];\n' ) runtime_exports = [json.loads(run(["node", name], consumer)) for name in ( diff --git a/sdk/README.md b/sdk/README.md index f97d2c206..a440b633e 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -164,33 +164,6 @@ provider before selecting a non-default callback. An older backend ignores the new field and uses its default; the SDK does not silently retry with a different callback. Native Apple sign-in and the Rust SDK are unchanged. -### Conditional local user-credential cleanup (4.1.0) - -`captureUserCredentialSnapshot(apiUrl)` returns an opaque in-memory handle -for the currently persisted V2 user credentials, or `null` when none exist. -Capture it before awaiting the operation after which cleanup is needed. -`clearUserCredentialsIfCurrent(snapshot)` returns `true` only after persisting -the removal of that same credential pair and revision. It returns `false` for -an observed replacement, refresh, logout, or an already consumed handle. -Do not take a fresh snapshot merely to clear the replacement credentials. - -The handle exposes no tokens or account identity and cannot be copied, -serialized, or carried across a page reload or separate SDK instance. -Unavailable, unreadable, unsynchronized, malformed, or unwritable persistent -storage raises an error; a storage error leaves the handle retryable. These -operations never substitute or republish an in-memory fallback. A successful -clear invalidates the current SDK instance's React user state. It does not -call server logout, revoke tokens, or clear API keys, platform credentials, -other API origins, legacy global token slots, or the cache namespace root. - -**Concurrency limit:** [Web Storage](https://html.spec.whatwg.org/multipage/webstorage.html#introduction) -does not guarantee cross-tab locking. The API rejects changes observed before -its write, including a different token pair with a reused revision, but another context can still write between -the read and write. Applications requiring protection against simultaneous -writers must coordinate every mutation of the shared credential storage before -adopting this cleanup path. Locking only cleanup is insufficient. This API does -not claim that stronger cross-tab guarantee. - ### Development Use the pinned Nix shell and Bun version. `bun.lock` is the supported dependency diff --git a/sdk/bun.lock b/sdk/bun.lock index 7ade54522..7d41f24f9 100644 --- a/sdk/bun.lock +++ b/sdk/bun.lock @@ -20,14 +20,12 @@ "@noble/hashes": "1.8.0", "@types/bun": "1.1.13", "@types/react": "19.2.18", - "@types/react-test-renderer": "18.3.1", "@vitejs/plugin-react": "4.7.0", "ajv": "8.20.0", "eslint": "9.39.5", "eslint-plugin-react-hooks": "5.2.0", "globals": "15.15.0", "prettier": "3.9.6", - "react-test-renderer": "18.3.1", "typescript": "5.6.3", "typescript-eslint": "8.66.0", "vite": "6.4.3", @@ -310,12 +308,8 @@ "@types/node": ["@types/node@20.12.14", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-scnD59RpYD91xngrQQLGkE+6UrHUPzeKZWhhjBSa3HSkwjbQc38+q3RoIVEwxQGRw3M+j5hpNAM+lgV3cVormg=="], - "@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="], - "@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="], - "@types/react-test-renderer": ["@types/react-test-renderer@18.3.1", "", { "dependencies": { "@types/react": "^18" } }, "sha512-vAhnk0tG2eGa37lkU9+s5SoroCsRI08xnsWFiAXOuPH2jqzMbcXvKExXViPi1P5fIklDeCvXqyrdmipFaSkZrA=="], - "@types/ws": ["@types/ws@8.5.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw=="], "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.66.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.66.0", "@typescript-eslint/type-utils": "8.66.0", "@typescript-eslint/utils": "8.66.0", "@typescript-eslint/visitor-keys": "8.66.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.66.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A=="], @@ -554,8 +548,6 @@ "node-releases": ["node-releases@2.0.53", "", {}, "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ=="], - "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], - "openai": ["openai@5.23.2", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-MQBzmTulj+MM5O8SKEk/gL8a7s5mktS9zUtAkU257WjvobGc9nKcBuVwjyEEcb9SI8a8Y2G/mzn3vm9n1Jlleg=="], "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], @@ -598,14 +590,8 @@ "react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], - "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], - "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], - "react-shallow-renderer": ["react-shallow-renderer@16.15.0", "", { "dependencies": { "object-assign": "^4.1.1", "react-is": "^16.12.0 || ^17.0.0 || ^18.0.0" }, "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0" } }, "sha512-oScf2FqQ9LFVQgA73vr86xl2NaOIX73rh+YFqcOp68CWj56tSfgtGKrEbyhCj0rSijyG9M1CYprTh39fBi5hzA=="], - - "react-test-renderer": ["react-test-renderer@18.3.1", "", { "dependencies": { "react-is": "^18.3.1", "react-shallow-renderer": "^16.15.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-KkAgygexHUkQqtvvx/otwxtuFu5cVjfzTCtjXLH9boS19/Nbtg84zS7wIQn39G8IlrhThBpQsMKkq5ZHZIYFXA=="], - "reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], @@ -616,8 +602,6 @@ "rollup": ["rollup@4.62.4", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.62.4", "@rollup/rollup-android-arm64": "4.62.4", "@rollup/rollup-darwin-arm64": "4.62.4", "@rollup/rollup-darwin-x64": "4.62.4", "@rollup/rollup-freebsd-arm64": "4.62.4", "@rollup/rollup-freebsd-x64": "4.62.4", "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", "@rollup/rollup-linux-arm-musleabihf": "4.62.4", "@rollup/rollup-linux-arm64-gnu": "4.62.4", "@rollup/rollup-linux-arm64-musl": "4.62.4", "@rollup/rollup-linux-loong64-gnu": "4.62.4", "@rollup/rollup-linux-loong64-musl": "4.62.4", "@rollup/rollup-linux-ppc64-gnu": "4.62.4", "@rollup/rollup-linux-ppc64-musl": "4.62.4", "@rollup/rollup-linux-riscv64-gnu": "4.62.4", "@rollup/rollup-linux-riscv64-musl": "4.62.4", "@rollup/rollup-linux-s390x-gnu": "4.62.4", "@rollup/rollup-linux-x64-gnu": "4.62.4", "@rollup/rollup-linux-x64-musl": "4.62.4", "@rollup/rollup-openbsd-x64": "4.62.4", "@rollup/rollup-openharmony-arm64": "4.62.4", "@rollup/rollup-win32-arm64-msvc": "4.62.4", "@rollup/rollup-win32-ia32-msvc": "4.62.4", "@rollup/rollup-win32-x64-gnu": "4.62.4", "@rollup/rollup-win32-x64-msvc": "4.62.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg=="], - "scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], - "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], @@ -700,8 +684,6 @@ "@rushstack/ts-command-line/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], - "@types/react-test-renderer/@types/react": ["@types/react@18.3.31", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw=="], - "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.6", "", {}, "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw=="], "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], diff --git a/sdk/package.json b/sdk/package.json index 3081680ee..c3f3deca9 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -57,14 +57,12 @@ "@noble/hashes": "1.8.0", "@types/bun": "1.1.13", "@types/react": "19.2.18", - "@types/react-test-renderer": "18.3.1", "@vitejs/plugin-react": "4.7.0", "ajv": "8.20.0", "eslint": "9.39.5", "eslint-plugin-react-hooks": "5.2.0", "globals": "15.15.0", "prettier": "3.9.6", - "react-test-renderer": "18.3.1", "typescript": "5.6.3", "typescript-eslint": "8.66.0", "vite": "6.4.3", diff --git a/sdk/src/lib/index.ts b/sdk/src/lib/index.ts index 64b3f3c3b..e92f002b2 100644 --- a/sdk/src/lib/index.ts +++ b/sdk/src/lib/index.ts @@ -78,12 +78,6 @@ export { createApiKey, listApiKeys, deleteApiKey } from "./api"; export { mintNativeHandoffGrant } from "./api"; -export { - captureUserCredentialSnapshot, - clearUserCredentialsIfCurrent, - type UserCredentialSnapshot -} from "./transportV2/auth"; - export { prepareNativeOAuthHandoff, readNativeUserAuth, diff --git a/sdk/src/lib/test/userCredentialCleanup.test.ts b/sdk/src/lib/test/userCredentialCleanup.test.ts deleted file mode 100644 index 75421bbed..000000000 --- a/sdk/src/lib/test/userCredentialCleanup.test.ts +++ /dev/null @@ -1,409 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { - captureUserCredentialSnapshot, - clearUserCredentialsIfCurrent, - type UserCredentialSnapshot -} from "../index"; -import { - TransportV2AuthorityChangedError, - getOrCreateTransportV2CacheRoot, - installTransportV2Credentials, - readTransportV2Credentials, - subscribeTransportV2AuthInvalidation, - type TransportV2AuthKind -} from "../transportV2/auth"; -import { TransportV2AuthRuntime } from "../transportV2/authRuntime"; -import type { TransportV2Runtime, TransportV2RuntimeRequest } from "../transportV2/runtime"; - -const STORAGE_PREFIX = "opensecret:transport-v2:auth:v1:"; -const AUDIENCE_PREFIX = "urn:opensecret:internal:transport-v2:"; - -class TestStorage implements Storage { - readonly values = new Map(); - readError = false; - writeError = false; - writes = 0; - - get length(): number { - return this.values.size; - } - - clear(): void { - this.values.clear(); - } - - getItem(key: string): string | null { - if (this.readError) throw new Error("test storage read denied"); - return this.values.get(key) ?? null; - } - - key(index: number): string | null { - return [...this.values.keys()][index] ?? null; - } - - removeItem(key: string): void { - this.values.delete(key); - } - - setItem(key: string, value: string): void { - this.writes += 1; - if (this.writeError) throw new Error("test storage write denied"); - this.values.set(key, value); - } -} - -let storage: TestStorage; -let apiUrl: string; -let otherApiUrl: string; -let testId = 0; -let originalStorage: PropertyDescriptor | undefined; -let originalFetch: PropertyDescriptor | undefined; -let fetchCalls = 0; -let unsubscribe: Array<() => void>; - -function restoreGlobal(name: string, descriptor: PropertyDescriptor | undefined): void { - if (descriptor) Object.defineProperty(globalThis, name, descriptor); - else Reflect.deleteProperty(globalThis, name); -} - -function exposeStorage(): void { - Object.defineProperty(globalThis, "localStorage", { - configurable: true, - writable: true, - value: storage - }); -} - -function token( - kind: TransportV2AuthKind, - purpose: "access" | "refresh", - user: string, - marker: number -): string { - const claims = { - aud: `${AUDIENCE_PREFIX}${kind}:${purpose}-token`, - sub: user, - exp: 2_100_000_000 + marker, - ...(kind === "user" ? { tf: 2 } : {}), - marker - }; - return [ - Buffer.from(JSON.stringify({ alg: "ES256K", typ: "JWT" })).toString("base64url"), - Buffer.from(JSON.stringify(claims)).toString("base64url"), - Buffer.from(new Uint8Array(64).fill(marker)).toString("base64url") - ].join("."); -} - -function pair(user = "user-a", marker = 1, kind: TransportV2AuthKind = "user") { - return { - access: token(kind, "access", user, marker), - refresh: token(kind, "refresh", user, marker) - }; -} - -function install(user = "user-a", marker = 1, api = apiUrl, kind: TransportV2AuthKind = "user") { - const credentials = pair(user, marker, kind); - installTransportV2Credentials(api, kind, credentials.access, credentials.refresh); - return credentials; -} - -function key(api = apiUrl): string { - return `${STORAGE_PREFIX}${Buffer.from(new URL(api).origin).toString("base64url")}`; -} - -function captured(): UserCredentialSnapshot { - const snapshot = captureUserCredentialSnapshot(apiUrl); - expect(snapshot).not.toBeNull(); - if (!snapshot) throw new Error("test credentials were not captured"); - return snapshot; -} - -function listen(api: string, kind: TransportV2AuthKind, listener: () => void): void { - unsubscribe.push(subscribeTransportV2AuthInvalidation(api, kind, listener)); -} - -beforeEach(() => { - originalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); - originalFetch = Object.getOwnPropertyDescriptor(globalThis, "fetch"); - storage = new TestStorage(); - testId += 1; - apiUrl = `https://cleanup-${testId}.example.test/service`; - otherApiUrl = `https://other-cleanup-${testId}.example.test/service`; - unsubscribe = []; - fetchCalls = 0; - exposeStorage(); - Object.defineProperty(globalThis, "fetch", { - configurable: true, - writable: true, - value: async () => { - fetchCalls += 1; - throw new Error("credential cleanup must not make a network request"); - } - }); -}); - -afterEach(() => { - for (const stop of unsubscribe) stop(); - restoreGlobal("localStorage", originalStorage); - restoreGlobal("fetch", originalFetch); -}); - -describe("public conditional user credential cleanup", () => { - test("captures a frozen tokenless handle and clears only its current user slot", () => { - const credentials = install(); - install("platform-user", 2, apiUrl, "platform"); - install("other-user", 3, otherApiUrl); - const root = getOrCreateTransportV2CacheRoot(apiUrl); - const before = JSON.parse(storage.getItem(key())!); - const otherBefore = storage.getItem(key(otherApiUrl)); - for (const [name, value] of Object.entries({ - access_token: "legacy-access", - refresh_token: "legacy-refresh", - api_key: "unrelated-api-key" - })) - storage.setItem(name, value); - let userNotifications = 0; - let otherNotifications = 0; - listen(apiUrl, "user", () => { - userNotifications += 1; - }); - listen(apiUrl, "platform", () => { - otherNotifications += 1; - }); - listen(otherApiUrl, "user", () => { - otherNotifications += 1; - }); - - const snapshot = captured(); - expect(Object.isFrozen(snapshot)).toBe(true); - expect(Object.keys(snapshot)).toEqual([]); - expect(JSON.stringify(snapshot)).toBe("{}"); - expect(Object.values(snapshot)).not.toContain(credentials.access); - expect(Object.values(snapshot)).not.toContain(credentials.refresh); - const secondSnapshot = captured(); - expect(clearUserCredentialsIfCurrent(snapshot)).toBe(true); - const after = JSON.parse(storage.getItem(key())!); - expect(after.user).toEqual({ revision: before.user.revision + 1, credentials: null }); - expect(after.platform).toEqual(before.platform); - expect(after.cache_namespace_root).toBe(before.cache_namespace_root); - expect(getOrCreateTransportV2CacheRoot(apiUrl)).toEqual(root); - expect(storage.getItem(key(otherApiUrl))).toBe(otherBefore); - expect(storage.getItem("access_token")).toBe("legacy-access"); - expect(storage.getItem("refresh_token")).toBe("legacy-refresh"); - expect(storage.getItem("api_key")).toBe("unrelated-api-key"); - expect(readTransportV2Credentials(apiUrl, "user")).toBeNull(); - expect(clearUserCredentialsIfCurrent(snapshot)).toBe(false); - expect(clearUserCredentialsIfCurrent(secondSnapshot)).toBe(false); - expect(userNotifications).toBe(1); - expect(otherNotifications).toBe(0); - expect(fetchCalls).toBe(0); - }); - - test("returns null for no durable user credentials without creating storage", () => { - expect(captureUserCredentialSnapshot(apiUrl)).toBeNull(); - expect(storage.length).toBe(0); - install("platform-user", 2, apiUrl, "platform"); - const before = storage.getItem(key()); - const writes = storage.writes; - expect(captureUserCredentialSnapshot(apiUrl)).toBeNull(); - expect(storage.getItem(key())).toBe(before); - expect(storage.writes).toBe(writes); - }); - - for (const replacement of [ - { description: "account switch", user: "user-b", marker: 2 }, - { description: "same-user refresh", user: "user-a", marker: 2 }, - { description: "same-user re-login with identical tokens", user: "user-a", marker: 1 } - ]) { - test(`preserves credentials after ${replacement.description}`, () => { - install(); - const snapshot = captured(); - install(replacement.user, replacement.marker); - const before = storage.getItem(key()); - let notifications = 0; - listen(apiUrl, "user", () => { - notifications += 1; - }); - expect(clearUserCredentialsIfCurrent(snapshot)).toBe(false); - expect(storage.getItem(key())).toBe(before); - expect(notifications).toBe(0); - expect(clearUserCredentialsIfCurrent(captured())).toBe(true); - expect(notifications).toBe(1); - }); - } - - for (const changedToken of ["access_token", "refresh_token"] as const) { - test(`observes a sequential external ${changedToken} replacement even at the same revision`, () => { - install(); - const snapshot = captured(); - const original = storage.getItem(key())!; - const external = JSON.parse(original); - const replacement = pair("user-a", 7); - external.user.credentials[changedToken] = - changedToken === "access_token" ? replacement.access : replacement.refresh; - storage.setItem(key(), JSON.stringify(external)); - const before = storage.getItem(key()); - expect(clearUserCredentialsIfCurrent(snapshot)).toBe(false); - expect(storage.getItem(key())).toBe(before); - // A stale handle is consumed even if a later writer restores its old bytes. - storage.setItem(key(), original); - expect(clearUserCredentialsIfCurrent(snapshot)).toBe(false); - expect(storage.getItem(key())).toBe(original); - }); - } - - test("does not clear recreated storage with a reused revision and different credentials", () => { - install(); - const snapshot = captured(); - storage.removeItem(key()); - install("user-a", 8); - expect(JSON.parse(storage.getItem(key())!).user.revision).toBe(1); - const before = storage.getItem(key()); - expect(clearUserCredentialsIfCurrent(snapshot)).toBe(false); - expect(storage.getItem(key())).toBe(before); - expect(clearUserCredentialsIfCurrent(captured())).toBe(true); - }); - - test("returns false after durable removal without restoring its memory copy", () => { - install(); - const snapshot = captured(); - storage.removeItem(key()); - const writes = storage.writes; - expect(clearUserCredentialsIfCurrent(snapshot)).toBe(false); - expect(captureUserCredentialSnapshot(apiUrl)).toBeNull(); - expect(storage.getItem(key())).toBeNull(); - expect(storage.writes).toBe(writes); - }); - - test("rejects forged, copied and serialized handles without consuming the real one", () => { - install(); - const snapshot = captured(); - for (const forged of [{}, { ...snapshot }, JSON.parse(JSON.stringify(snapshot)), null]) { - expect(() => clearUserCredentialsIfCurrent(forged as UserCredentialSnapshot)).toThrow(); - } - expect(clearUserCredentialsIfCurrent(snapshot)).toBe(true); - }); - - for (const failure of ["missing", "getter", "read"] as const) { - test(`fails closed on ${failure} storage without notifying or consuming the handle`, () => { - install(); - const snapshot = captured(); - const before = storage.getItem(key()); - let notifications = 0; - listen(apiUrl, "user", () => { - notifications += 1; - }); - if (failure === "missing") Reflect.deleteProperty(globalThis, "localStorage"); - if (failure === "getter") { - Object.defineProperty(globalThis, "localStorage", { - configurable: true, - get() { - throw new Error("test storage inaccessible"); - } - }); - } - if (failure === "read") storage.readError = true; - expect(() => captureUserCredentialSnapshot(apiUrl)).toThrow(); - expect(() => clearUserCredentialsIfCurrent(snapshot)).toThrow(); - expect(notifications).toBe(0); - storage.readError = false; - exposeStorage(); - expect(storage.getItem(key())).toBe(before); - expect(clearUserCredentialsIfCurrent(snapshot)).toBe(true); - expect(notifications).toBe(1); - }); - } - - test("keeps failed persistent writes retryable and never reports successful cleanup", () => { - install(); - const snapshot = captured(); - const before = storage.getItem(key()); - let notifications = 0; - listen(apiUrl, "user", () => { - notifications += 1; - }); - storage.writeError = true; - expect(() => clearUserCredentialsIfCurrent(snapshot)).toThrow("could not be persisted"); - expect(storage.getItem(key())).toBe(before); - expect(notifications).toBe(0); - storage.writeError = false; - expect(clearUserCredentialsIfCurrent(snapshot)).toBe(true); - expect(notifications).toBe(1); - }); - - test("rejects malformed durable state rather than clearing its last good memory copy", () => { - install(); - const snapshot = captured(); - const original = storage.getItem(key())!; - storage.setItem(key(), "{invalid"); - expect(() => captureUserCredentialSnapshot(apiUrl)).toThrow(); - expect(() => clearUserCredentialsIfCurrent(snapshot)).toThrow(); - expect(storage.getItem(key())).toBe("{invalid"); - storage.setItem(key(), original); - expect(clearUserCredentialsIfCurrent(snapshot)).toBe(true); - }); - - test("never captures or republishes a memory-only installation", () => { - storage.writeError = true; - install(); - expect(storage.getItem(key())).toBeNull(); - storage.writeError = false; - const writes = storage.writes; - expect(() => captureUserCredentialSnapshot(apiUrl)).toThrow("synchronized persistent storage"); - expect(storage.getItem(key())).toBeNull(); - expect(storage.writes).toBe(writes); - }); - - test("does not use a durable old snapshot while a newer installation exists only in memory", () => { - install(); - const snapshot = captured(); - const before = storage.getItem(key()); - storage.writeError = true; - install("user-b", 9); - storage.writeError = false; - const writes = storage.writes; - let notifications = 0; - listen(apiUrl, "user", () => { - notifications += 1; - }); - expect(() => captureUserCredentialSnapshot(apiUrl)).toThrow("synchronized persistent storage"); - expect(() => clearUserCredentialsIfCurrent(snapshot)).toThrow( - "synchronized persistent storage" - ); - expect(storage.getItem(key())).toBe(before); - expect(storage.writes).toBe(writes); - expect(notifications).toBe(0); - }); - - test("an already-pending refresh cannot reinstall credentials after successful cleanup", async () => { - install(); - const snapshot = captured(); - const refreshed = pair("user-a", 12); - let complete!: (response: Response) => void; - const pendingResponse = new Promise((resolve) => { - complete = resolve; - }); - let refreshRequests = 0; - const runtime = { - async request(input: TransportV2RuntimeRequest) { - input.beforeSend?.(); - expect(input.request.target).toBe("/refresh"); - refreshRequests += 1; - return { response: await pendingResponse, rememberOAuthContinuation() {} }; - } - } as unknown as TransportV2Runtime; - const auth = new TransportV2AuthRuntime({ runtime, nowUnixSeconds: () => 1_900_000_000 }); - const pendingRefresh = auth.refresh(apiUrl, { remoteAttestation: false }, "user"); - const outcome = pendingRefresh.then( - () => ({ error: undefined }), - (error: unknown) => ({ error }) - ); - expect(refreshRequests).toBe(1); - expect(clearUserCredentialsIfCurrent(snapshot)).toBe(true); - complete(Response.json({ access_token: refreshed.access, refresh_token: refreshed.refresh })); - expect((await outcome).error).toBeInstanceOf(TransportV2AuthorityChangedError); - expect(readTransportV2Credentials(apiUrl, "user")).toBeNull(); - expect(JSON.parse(storage.getItem(key())!).user.credentials).toBeNull(); - expect(fetchCalls).toBe(0); - }); -}); diff --git a/sdk/src/lib/test/userCredentialCleanupReact.test.ts b/sdk/src/lib/test/userCredentialCleanupReact.test.ts deleted file mode 100644 index bd4e12f57..000000000 --- a/sdk/src/lib/test/userCredentialCleanupReact.test.ts +++ /dev/null @@ -1,243 +0,0 @@ -import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; -import { createElement } from "react"; -import TestRenderer, { act, type ReactTestRenderer } from "react-test-renderer"; -import { - captureUserCredentialSnapshot, - clearUserCredentialsIfCurrent, - OpenSecretProvider, - useOpenSecret, - type OpenSecretContextType, - type PcrConfig, - type UserResponse -} from "../index"; -import * as api from "../api"; -import { apiConfig } from "../apiConfig"; -import { - clearTransportV2Credentials, - installTransportV2Credentials, - readTransportV2Credentials -} from "../transportV2/auth"; -import { transportV2Runtime } from "../transportV2/runtime"; - -const API_URL = "https://react-user-cleanup.example.test/backend"; -const CLIENT_ID = "00000000-0000-4000-8000-000000000001"; -const API_KEY = "00000000-0000-4000-8000-000000000002"; -const PCR_CONFIG: PcrConfig = { environment: "development" }; - -let renderer: ReactTestRenderer | undefined; -let observed: OpenSecretContextType | undefined; -let respondWithProfile: (principalId: string) => Promise; -let requestedPrincipals: string[]; -let unexpectedTransportRequests: string[]; -let unexpectedNetworkRequests: number; -let restoreSpies: Array<() => void>; -let previousApiUrl: string; -let previousPcrConfig: PcrConfig; -let previousConfiguredAppUrl: string; -let previousConfiguredPlatformUrl: string; - -function token(principalId: string, purpose: "access" | "refresh", version: number): string { - const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url"); - return [ - encode({ alg: "ES256K", typ: "JWT" }), - encode({ - aud: `urn:opensecret:internal:transport-v2:user:${purpose}-token`, - sub: principalId, - tf: 2, - exp: 4_000_000_000 + version - }), - Buffer.from(new Uint8Array(64).fill(1)).toString("base64url") - ].join("."); -} - -function installUser(principalId: string, version = 0) { - return installTransportV2Credentials( - API_URL, - "user", - token(principalId, "access", version), - token(principalId, "refresh", version) - ); -} - -function profile(principalId: string, name = "Initial profile"): UserResponse { - return { - user: { - id: principalId, - name, - email: `${principalId}@example.test`, - email_verified: true, - login_method: "google", - created_at: "2026-01-01T00:00:00Z", - updated_at: "2026-01-01T00:00:00Z" - } - }; -} - -function deferred() { - let resolve!: (value: T) => void; - const promise = new Promise((fulfill) => { - resolve = fulfill; - }); - return { promise, resolve }; -} - -function current(): OpenSecretContextType { - if (!observed) throw new Error("The provider consumer has not rendered."); - return observed; -} - -function Consumer() { - observed = useOpenSecret(); - return null; -} - -async function mountProvider(): Promise { - await act(async () => { - const element = createElement(OpenSecretProvider, { - apiUrl: API_URL, - clientId: CLIENT_ID, - pcrConfig: PCR_CONFIG, - children: createElement(Consumer) - }); - // The SDK uses React 19 types for its React 18/19 peer range; this renderer - // has React 18 types. Both runtime packages are pinned to React 18.3.1. - renderer = TestRenderer.create(element as Parameters[0]); - }); -} - -beforeEach(() => { - previousApiUrl = api.getApiUrl(); - previousPcrConfig = api.getApiPcrConfig(); - previousConfiguredAppUrl = apiConfig.appApiUrl; - previousConfiguredPlatformUrl = apiConfig.platformApiUrl; - clearTransportV2Credentials(API_URL); - observed = undefined; - requestedPrincipals = []; - unexpectedTransportRequests = []; - unexpectedNetworkRequests = 0; - respondWithProfile = async (principalId) => profile(principalId); - - // Keep the real provider, credential store, authority selection, and profile - // publication fence. A successful deferred API result must be discarded by - // the provider itself, independently of lower-level response guards. - const fetchProfile = spyOn(api, "fetchUserWithTransportV2Authority").mockImplementation( - async (_apiUrl, _pcrConfig, authority) => { - const principalId = authority.credentials.principalId; - requestedPrincipals.push(principalId); - return respondWithProfile(principalId); - } - ); - const request = spyOn(transportV2Runtime, "request").mockImplementation(async (input) => { - unexpectedTransportRequests.push(input.request.target); - throw new Error("This provider cleanup test must not make a transport request."); - }); - const fetch = spyOn(globalThis, "fetch").mockImplementation(async () => { - unexpectedNetworkRequests += 1; - throw new Error("This provider cleanup test must not access the network."); - }); - restoreSpies = [ - () => fetchProfile.mockRestore(), - () => request.mockRestore(), - () => fetch.mockRestore() - ]; -}); - -afterEach(async () => { - try { - await act(async () => { - renderer?.unmount(); - }); - } finally { - renderer = undefined; - observed = undefined; - for (const restore of restoreSpies) restore(); - clearTransportV2Credentials(API_URL); - api.setApiUrl(previousApiUrl, previousPcrConfig); - apiConfig.configure(previousConfiguredAppUrl, previousConfiguredPlatformUrl); - } - expect(unexpectedTransportRequests).toEqual([]); - expect(unexpectedNetworkRequests).toBe(0); -}); - -describe("public user credential cleanup through the React provider", () => { - test("clears the published user while preserving the separately configured API key", async () => { - installUser("first-user"); - await mountProvider(); - expect(current().auth).toEqual({ loading: false, user: profile("first-user") }); - const snapshot = captureUserCredentialSnapshot(API_URL); - expect(snapshot).not.toBeNull(); - - await act(async () => { - current().setApiKey(API_KEY); - }); - expect(current().apiKey).toBe(API_KEY); - - await act(async () => { - expect(clearUserCredentialsIfCurrent(snapshot!)).toBe(true); - }); - expect(current().auth).toEqual({ loading: false, user: undefined }); - expect(current().apiKey).toBe(API_KEY); - expect(readTransportV2Credentials(API_URL, "user")).toBeNull(); - expect(requestedPrincipals).toEqual(["first-user"]); - }); - - test("a deferred successful profile result cannot republish the user after cleanup", async () => { - installUser("first-user"); - await mountProvider(); - expect(current().auth.user).toEqual(profile("first-user")); - const snapshot = captureUserCredentialSnapshot(API_URL); - expect(snapshot).not.toBeNull(); - - const started = deferred(); - const pendingProfile = deferred(); - respondWithProfile = () => { - started.resolve(); - return pendingProfile.promise; - }; - let refetch!: Promise; - await act(async () => { - refetch = current().refetchUser(); - await started.promise; - }); - expect(requestedPrincipals).toEqual(["first-user", "first-user"]); - - await act(async () => { - expect(clearUserCredentialsIfCurrent(snapshot!)).toBe(true); - }); - expect(current().auth).toEqual({ loading: false, user: undefined }); - - await act(async () => { - pendingProfile.resolve(profile("first-user", "Deferred stale profile")); - await refetch; - }); - expect(current().auth).toEqual({ loading: false, user: undefined }); - expect(readTransportV2Credentials(API_URL, "user")).toBeNull(); - expect(requestedPrincipals).toEqual(["first-user", "first-user"]); - }); - - for (const replacementId of ["first-user", "second-user"]) { - test(`stale cleanup preserves the newer ${replacementId === "first-user" ? "same-account" : "other-account"} profile`, async () => { - installUser("first-user"); - await mountProvider(); - expect(current().auth.user).toEqual(profile("first-user")); - const snapshot = captureUserCredentialSnapshot(API_URL); - expect(snapshot).not.toBeNull(); - - respondWithProfile = async (principalId) => profile(principalId, "Newer login profile"); - let installed!: ReturnType; - await act(async () => { - installed = installUser(replacementId, 1); - await current().refetchUser(); - }); - const newerProfile = profile(replacementId, "Newer login profile"); - expect(current().auth).toEqual({ loading: false, user: newerProfile }); - - await act(async () => { - expect(clearUserCredentialsIfCurrent(snapshot!)).toBe(false); - }); - expect(current().auth).toEqual({ loading: false, user: newerProfile }); - expect(readTransportV2Credentials(API_URL, "user")).toEqual(installed); - expect(requestedPrincipals).toEqual(["first-user", replacementId]); - }); - } -}); diff --git a/sdk/src/lib/transportV2/auth.ts b/sdk/src/lib/transportV2/auth.ts index 46553dda2..dffff99b8 100644 --- a/sdk/src/lib/transportV2/auth.ts +++ b/sdk/src/lib/transportV2/auth.ts @@ -32,27 +32,6 @@ export interface TransportV2AuthSnapshot { revision: number; } -const userCredentialSnapshotBrand = Symbol("UserCredentialSnapshot"); - -/** - * An opaque, process-local handle for conditional user-credential cleanup. - * Keep the original object in memory; copying or serializing it is unsupported. - * The handle exposes no tokens, account identity, or storage keys. - */ -export interface UserCredentialSnapshot { - readonly [userCredentialSnapshotBrand]: true; -} - -interface CapturedUserCredentials { - apiOrigin: string; - revision: number; - accessToken: string; - refreshToken: string; -} - -const issuedUserCredentialSnapshots = new WeakSet(); -const capturedUserCredentials = new WeakMap(); - export type TransportV2ProfilePublicationDecision = "publish" | "reload" | "discard"; export class TransportV2AuthorityChangedError extends Error { @@ -672,98 +651,6 @@ export function clearTransportV2CredentialsIfCurrent(expected: TransportV2AuthSn return true; } -function readPersistedStateForUserCleanup(apiOrigin: string): { - state: PersistedState; - key: string; - storage: Storage; -} { - const persistent = persistentStorageResult(); - if (persistent.kind !== "available") { - throw new Error("User credential cleanup requires accessible persistent storage."); - } - const key = storageKey(apiOrigin); - // Reading via readBlob could substitute or republish a stale memory copy. - // A caller must resolve an unpersisted write before capturing or clearing. - if (fallbackOnlyKeys.has(key) || pendingRemovalKeys.has(key)) { - throw new Error("User credential cleanup requires synchronized persistent storage."); - } - let raw: string | null; - try { - raw = persistent.storage.getItem(key); - } catch { - throw new Error("User credential cleanup could not read persistent storage."); - } - return { - state: raw === null ? emptyState(apiOrigin) : parseState(raw, apiOrigin), - key, - storage: persistent.storage - }; -} - -/** - * Capture the currently persisted V2 user credentials for this API origin. - * Returns null when there are none; throws on unavailable, unsynchronized, or - * malformed storage. Capture before the asynchronous work that needs cleanup. - * This does not copy credentials into the returned handle or perform network I/O. - */ -export function captureUserCredentialSnapshot(apiUrl: string): UserCredentialSnapshot | null { - const apiOrigin = canonicalizeTransportV2ApiOrigin(apiUrl); - const { state } = readPersistedStateForUserCleanup(apiOrigin); - const credentials = credentialsFromSlot(apiOrigin, "user", state.user); - if (!credentials) return null; - const snapshot: UserCredentialSnapshot = Object.freeze({ - [userCredentialSnapshotBrand]: true as const - }); - issuedUserCredentialSnapshots.add(snapshot); - capturedUserCredentials.set(snapshot, { - apiOrigin, - revision: credentials.revision, - accessToken: credentials.accessToken, - refreshToken: credentials.refreshToken - }); - return snapshot; -} - -/** - * Clear the captured V2 user credentials only if the persisted pair and revision - * still match. Returns false for an observed replacement or a consumed handle. - * Storage errors throw and leave the handle retryable; invalid handles throw. - * Success invalidates the local provider's user state, but does not revoke a - * server session or clear API keys, platform credentials, or the cache root. - * - * localStorage has no atomic compare-and-swap across browser contexts. This - * fences changes observed before the write, not a simultaneous write by another - * tab. Callers requiring that stronger guarantee must coordinate all writers. - * Never recapture on a stale result merely to force cleanup of newer credentials. - */ -export function clearUserCredentialsIfCurrent(snapshot: UserCredentialSnapshot): boolean { - if (!issuedUserCredentialSnapshots.has(snapshot)) { - throw new TypeError("Expected an original user credential snapshot from this SDK instance."); - } - const expected = capturedUserCredentials.get(snapshot); - if (!expected) return false; - const { state, key, storage } = readPersistedStateForUserCleanup(expected.apiOrigin); - if ( - state.user.revision !== expected.revision || - state.user.credentials?.access_token !== expected.accessToken || - state.user.credentials?.refresh_token !== expected.refreshToken - ) { - capturedUserCredentials.delete(snapshot); - return false; - } - state.user = { revision: nextRevision(state.user.revision), credentials: null }; - const encoded = JSON.stringify(state); - try { - storage.setItem(key, encoded); - } catch { - throw new Error("User credential cleanup could not be persisted."); - } - memoryBlobs.set(key, encoded); - capturedUserCredentials.delete(snapshot); - notifyInvalidated(expected.apiOrigin, "user"); - return true; -} - export function clearTransportV2Credentials(apiUrl: string, kind?: TransportV2AuthKind): void { const apiOrigin = canonicalizeTransportV2ApiOrigin(apiUrl); let state: PersistedState;