From 691764160c58ad6d5b5a1cf4df65cbe7ee285c62 Mon Sep 17 00:00:00 2001 From: Andrew Fuller Date: Fri, 28 Aug 2026 17:48:38 -0400 Subject: [PATCH 1/4] feat(sdk-typescript): add application-owned OAuth PKCE flow --- packages/sdk-typescript/README.md | 52 +++ .../scripts/api-surface.snapshot.json | 80 +++- .../sdk-typescript/scripts/verify-package.mjs | 8 +- .../sdk-typescript/src/auth/bearer.test.ts | 28 ++ packages/sdk-typescript/src/auth/bearer.ts | 38 ++ .../src/auth/browser-oauth.test.ts | 10 + .../sdk-typescript/src/auth/oauth.test.ts | 258 +++++++++++- packages/sdk-typescript/src/auth/oauth.ts | 384 +++++++++++++----- packages/sdk-typescript/src/auth/pkce.test.ts | 35 ++ packages/sdk-typescript/src/auth/pkce.ts | 39 ++ .../sdk-typescript/src/auth/token-values.ts | 16 + packages/sdk-typescript/src/browser/index.ts | 18 +- 12 files changed, 853 insertions(+), 113 deletions(-) create mode 100644 packages/sdk-typescript/src/auth/bearer.test.ts create mode 100644 packages/sdk-typescript/src/auth/bearer.ts create mode 100644 packages/sdk-typescript/src/auth/pkce.test.ts create mode 100644 packages/sdk-typescript/src/auth/pkce.ts create mode 100644 packages/sdk-typescript/src/auth/token-values.ts diff --git a/packages/sdk-typescript/README.md b/packages/sdk-typescript/README.md index 985ad24..526d4c4 100644 --- a/packages/sdk-typescript/README.md +++ b/packages/sdk-typescript/README.md @@ -84,6 +84,58 @@ For browser applications and Cloudflare Workers, import from `@gemini-markets/sd The package is ESM-only. Node.js applications should use `import` or dynamic `import()`; this package does not provide a CommonJS `require()` entry point. +### OAuth Authorization and Token Ownership + +The SDK provides OAuth protocol primitives and Bearer request authentication. Your application owns browser navigation, callback handling, and the storage implementation. + +For a browser or other public client, use `BrowserOAuthAuth`. It generates a state value and an S256 PKCE verifier, and returns both the authorization URL and the short-lived transaction that must survive until the callback. The transaction contains the verifier, so treat it as application-private and short-lived. `tokenStore` is an adapter supplied by your application; the SDK does not choose where or how OAuth tokens are stored. + +```ts +import { BrowserOAuthAuth, createClient } from "@gemini-markets/sdk/browser"; + +const auth = new BrowserOAuthAuth({ + env: "sandbox", + client: { + type: "public", + clientId: "my-public-client", + redirectUri: "https://my-app.example/callback", + }, + tokenStore, // Application-controlled storage; do not use localStorage for refresh tokens. +}); + +const authorization = await auth.beginAuthorization(["balances:read", "orders:read"]); +saveAuthorizationTransaction(authorization.transaction); // Keep this app-owned and short-lived. +window.location.assign(authorization.url); + +// In the application callback handler: +const transaction = loadAuthorizationTransaction(); +await auth.completeAuthorization(new URL(window.location.href), transaction); +const client = createClient({ env: "sandbox", auth }); +``` + +For web applications whose callback runs in a different request or process, provide an `authorizationTransactionStore`. The SDK saves the transaction from `beginAuthorization()` and atomically consumes it when `completeAuthorization(callback)` is called without a transaction. Keep this short-lived store separate from the long-lived OAuth token store, namespace both stores by user, client, and environment, enforce a short TTL in the transaction store, and make `consume` a real one-time operation. Without that store, complete the flow with the same auth instance and keep the returned transaction private. + +The token store is intentionally storage-agnostic. `OAuthTokenStore` can be implemented over a keychain, encrypted file, database, or application-controlled browser secure-storage layer. The application owns encryption, access control, serialization, and lifecycle; the SDK only calls the adapter to load, save, clear, and serialize refresh operations. + +If your application already owns token persistence and refresh, omit `tokenStore` for the authorization exchange and initialize the client with the resulting access token: + +```ts +import { BearerAuth, BrowserOAuthAuth, createClient } from "@gemini-markets/sdk/browser"; + +const oauth = new BrowserOAuthAuth({ + env: "sandbox", + client: { type: "public", clientId: "my-public-client", redirectUri: "https://my-app.example/callback" }, +}); +const authorization = await oauth.beginAuthorization(["balances:read"]); +// Open authorization.url and restore authorization.transaction in the callback. +const tokens = await oauth.completeAuthorization(callbackUrl, authorization.transaction); +const client = createClient({ env: "sandbox", auth: new BearerAuth({ accessToken: tokens.accessToken }) }); +``` + +`BearerAuth` does not persist or refresh credentials. Use it when the application owns the token lifecycle and only wants to hand the current access token to the SDK. Use `BrowserOAuthAuth` or server `OAuthAuth` with an `OAuthTokenStore` when the SDK should refresh and atomically save rotated refresh tokens through the application’s storage adapter. The SDK never opens a browser or starts a callback listener. + +Treat the authorization callback URL as sensitive until it has been processed: it contains a one-time code. Do not log or analytics-track the full URL, and remove its query parameters from browser history after handling it. OAuth endpoint overrides must use HTTPS; loopback HTTP redirect URIs are supported for local/native flows. + --- ### 2. 🎯 Prediction Markets diff --git a/packages/sdk-typescript/scripts/api-surface.snapshot.json b/packages/sdk-typescript/scripts/api-surface.snapshot.json index 50f69e7..bd41333 100644 --- a/packages/sdk-typescript/scripts/api-surface.snapshot.json +++ b/packages/sdk-typescript/scripts/api-surface.snapshot.json @@ -126,6 +126,16 @@ "name": "BalanceUpdate", "declaration": "export interface BalanceUpdate { e: 'balanceUpdate'; E: number | bigint; u: number | bigint; B: Balance[]; }" }, + { + "kind": "ClassDeclaration", + "name": "BearerAuth", + "declaration": "export declare class BearerAuth implements AuthStrategy { #private; readonly authCapability: \"bearer\"; constructor(options: BearerAuthOptions); nextNonce(): undefined; credentialHeaders(_payloadBase64: string): Promise>; }" + }, + { + "kind": "InterfaceDeclaration", + "name": "BearerAuthOptions", + "declaration": "export interface BearerAuthOptions { accessToken: string; }" + }, { "kind": "InterfaceDeclaration", "name": "BookDelta", @@ -144,7 +154,7 @@ { "kind": "TypeAliasDeclaration", "name": "BrowserClientOptions", - "declaration": "export type BrowserClientOptions = Omit & { auth?: BrowserOAuthAuth; };" + "declaration": "export type BrowserClientOptions = Omit & { auth?: BrowserOAuthAuth | import(\"../auth/bearer.js\").BearerAuth; };" }, { "kind": "TypeAliasDeclaration", @@ -226,6 +236,11 @@ "name": "createClient", "declaration": "export declare function createClient(options: BrowserClientOptions): BrowserGeminiMarkets;" }, + { + "kind": "FunctionDeclaration", + "name": "createPkceCodeChallenge", + "declaration": "export declare function createPkceCodeChallenge(codeVerifier: string): Promise;" + }, { "kind": "FunctionDeclaration", "name": "createResponseMetadata", @@ -244,7 +259,7 @@ { "kind": "VariableDeclaration", "name": "DEFAULT_OAUTH_ENDPOINTS", - "declaration": "DEFAULT_OAUTH_ENDPOINTS: { production: { api: string; authorization: string; token: string; }; sandbox: { api: string; authorization: string; token: string; }; }" + "declaration": "DEFAULT_OAUTH_ENDPOINTS: { production: { api: string; authorization: string; token: string; revocation: string; }; sandbox: { api: string; authorization: string; token: string; revocation: string; }; }" }, { "kind": "TypeAliasDeclaration", @@ -321,6 +336,11 @@ "name": "GeminiMarketsOptions", "declaration": "export interface GeminiMarketsOptions { /** Environment to connect to. This is required to prevent accidental live requests. */ env: Environment; /** Logger for SDK logs. Default: silent (`NOOP_LOGGER`). */ logger?: Logger; /** Receives safe diagnostics from REST, OAuth, WebSocket, and order-book operations. */ onDiagnostic?: DiagnosticListener; /** Authentication for private Prediction Markets REST methods. */ auth?: AuthStrategy; /** End-to-end timeout for REST and WebSocket waits. Default: 30 seconds. */ timeoutMs?: number; /** Optional application-level WebSocket liveness checks. */ webSocketLiveness?: { intervalMs?: number; timeoutMs?: number; }; /** Maximum inbound WebSocket message size in UTF-8 bytes. */ webSocketMaxMessageSizeBytes?: number; /** Exponential WebSocket reconnect backoff. */ webSocketBackoff?: { baseMs?: number; capMs?: number; factor?: number; }; /** Automatic WebSocket reconnect policy. Defaults to ten attempts. */ webSocketReconnect?: WebSocketReconnectOptions; /** WebSocket handshake timeout forwarded through the replaceable socket factory. */ webSocketHandshakeTimeoutMs?: number; /** Negotiate per-message compression when the runtime transport supports it. Default: false. */ webSocketPerMessageDeflate?: boolean; /** Retry count for generated safe REST reads only. Default: 5. */ maxRetries?: number; /** Maximum REST response body size. Default: 16 MiB. */ maxResponseSizeBytes?: number; /** Backoff settings for generated safe REST reads only. */ backoff?: { baseMs?: number; capMs?: number; factor?: number; }; /** Custom fetch implementation for REST instrumentation or routing. */ fetch?: FetchLike; /** Custom WebSocket factory for runtimes that need upgrade headers, such as `ws` for Node.js HMAC auth. */ webSocketFactory?: SocketFactory; /** Optional hook for request tracing and metrics. */ onRequest?: (payload: import(\"../transport/http.js\").RequestHookPayload) => void; /** Optional hook for response tracing and metrics. */ onResponse?: (payload: import(\"../transport/http.js\").ResponseHookPayload) => void; }" }, + { + "kind": "FunctionDeclaration", + "name": "generatePkceCodeVerifier", + "declaration": "export declare function generatePkceCodeVerifier(randomBytes?: RandomBytes): string;" + }, { "kind": "InterfaceDeclaration", "name": "GenericSuccessResponse", @@ -471,10 +491,15 @@ "name": "OAuthAuthorizationTransaction", "declaration": "export interface OAuthAuthorizationTransaction { state: string; /** Present only for public clients. Keep it private until the callback. */ codeVerifier?: string; }" }, + { + "kind": "InterfaceDeclaration", + "name": "OAuthAuthorizationTransactionStore", + "declaration": "export interface OAuthAuthorizationTransactionStore { /** Store this record with a short expiration and keep its verifier confidential. */ save(transaction: OAuthAuthorizationTransaction): Promise; /** Atomically return and delete the record for `state`; return undefined on replay or expiry. */ consume(state: string): Promise; }" + }, { "kind": "InterfaceDeclaration", "name": "OAuthEndpoints", - "declaration": "export interface OAuthEndpoints { api: string; authorization: string; token: string; }" + "declaration": "export interface OAuthEndpoints { api: string; authorization: string; token: string; revocation: string; }" }, { "kind": "ClassDeclaration", @@ -494,7 +519,7 @@ { "kind": "InterfaceDeclaration", "name": "OAuthTokenStore", - "declaration": "export interface OAuthTokenStore { load(): Promise; save(tokens: T): Promise; clear(): Promise; /** * Atomically claim an authorization state. Return `true` only for the first * claim and retain the claim for the transaction's short lifetime. * Implement this durably when authorization transactions can cross process * or page boundaries. */ consumeAuthorizationState(state: string): Promise; /** * Clear the stored record only when it still uses `refreshToken`. * A store shared by processes must implement this as a real compare-and-swap operation. */ clearIfCurrent?(refreshToken: string): Promise; runExclusive(operation: () => Promise): Promise; }" + "declaration": "export interface OAuthTokenStore { load(): Promise; save(tokens: T): Promise; clear(): Promise; /** * Legacy compatibility hook for atomically claiming an authorization state. * Prefer `authorizationTransactionStore` when authorization transactions are * stored separately from tokens. */ consumeAuthorizationState?(state: string): Promise; /** * Clear the stored record only when it still uses `refreshToken`. * A store shared by processes must implement this as a real compare-and-swap operation. */ clearIfCurrent?(refreshToken: string): Promise; runExclusive(operation: () => Promise): Promise; }" }, { "kind": "TypeAliasDeclaration", @@ -636,6 +661,11 @@ "name": "PublicWebSocket", "declaration": "export type PublicWebSocket = PublicGeminiWebSocket;" }, + { + "kind": "TypeAliasDeclaration", + "name": "RandomBytes", + "declaration": "export type RandomBytes = (size: number) => Uint8Array;" + }, { "kind": "ClassDeclaration", "name": "RateLimitError", @@ -1088,6 +1118,16 @@ "name": "BalanceUpdate", "declaration": "export interface BalanceUpdate { e: 'balanceUpdate'; E: number | bigint; u: number | bigint; B: Balance[]; }" }, + { + "kind": "ClassDeclaration", + "name": "BearerAuth", + "declaration": "export declare class BearerAuth implements AuthStrategy { #private; readonly authCapability: \"bearer\"; constructor(options: BearerAuthOptions); nextNonce(): undefined; credentialHeaders(_payloadBase64: string): Promise>; }" + }, + { + "kind": "InterfaceDeclaration", + "name": "BearerAuthOptions", + "declaration": "export interface BearerAuthOptions { accessToken: string; }" + }, { "kind": "InterfaceDeclaration", "name": "BookDelta", @@ -1106,7 +1146,7 @@ { "kind": "TypeAliasDeclaration", "name": "BrowserClientOptions", - "declaration": "export type BrowserClientOptions = Omit & { auth?: BrowserOAuthAuth; };" + "declaration": "export type BrowserClientOptions = Omit & { auth?: BrowserOAuthAuth | import(\"../auth/bearer.js\").BearerAuth; };" }, { "kind": "TypeAliasDeclaration", @@ -1188,6 +1228,11 @@ "name": "createClient", "declaration": "export declare function createClient(options: ServerClientOptions): Promise;" }, + { + "kind": "FunctionDeclaration", + "name": "createPkceCodeChallenge", + "declaration": "export declare function createPkceCodeChallenge(codeVerifier: string): Promise;" + }, { "kind": "FunctionDeclaration", "name": "createResponseMetadata", @@ -1206,7 +1251,7 @@ { "kind": "VariableDeclaration", "name": "DEFAULT_OAUTH_ENDPOINTS", - "declaration": "DEFAULT_OAUTH_ENDPOINTS: { production: { api: string; authorization: string; token: string; }; sandbox: { api: string; authorization: string; token: string; }; }" + "declaration": "DEFAULT_OAUTH_ENDPOINTS: { production: { api: string; authorization: string; token: string; revocation: string; }; sandbox: { api: string; authorization: string; token: string; revocation: string; }; }" }, { "kind": "TypeAliasDeclaration", @@ -1288,6 +1333,11 @@ "name": "GeminiWebSocketOptions", "declaration": "export type GeminiWebSocketOptions = ConstructorParameters[0] & { auth?: AuthStrategy; };" }, + { + "kind": "FunctionDeclaration", + "name": "generatePkceCodeVerifier", + "declaration": "export declare function generatePkceCodeVerifier(randomBytes?: RandomBytes): string;" + }, { "kind": "InterfaceDeclaration", "name": "GenericSuccessResponse", @@ -1446,12 +1496,12 @@ { "kind": "ClassDeclaration", "name": "OAuthAuth", - "declaration": "export declare class OAuthAuth implements AuthStrategy { #private; /** Runtime marker for server OAuth. BrowserOAuthAuth narrows this value. */ readonly authCapability: \"server\" | \"browser\"; constructor(options: OAuthAuthOptions); beginAuthorization(scopes: string[]): Promise; completeAuthorization(callback: string | URL, transaction: OAuthAuthorizationTransaction | undefined, options?: RequestOptions): Promise; nextNonce(): undefined; credentialHeaders(_payloadBase64: string, options?: RequestOptions): Promise>; revoke(options?: RequestOptions): Promise; }" + "declaration": "export declare class OAuthAuth implements AuthStrategy { #private; /** Runtime marker for server OAuth. BrowserOAuthAuth narrows this value. */ readonly authCapability: \"server\" | \"browser\"; constructor(options: OAuthAuthOptions); beginAuthorization(scopes: string[]): Promise; completeAuthorization(callback: string | URL, transaction?: OAuthAuthorizationTransaction, options?: RequestOptions): Promise; nextNonce(): undefined; credentialHeaders(_payloadBase64: string, options?: RequestOptions): Promise>; revoke(options?: RequestOptions): Promise; }" }, { "kind": "InterfaceDeclaration", "name": "OAuthAuthOptions", - "declaration": "export interface OAuthAuthOptions { client: OAuthClient; tokenStore: OAuthTokenStore; /** OAuth environment. Required to prevent accidental live authorization. */ env: Environment; /** OAuth endpoint overrides for tests, mocks, or proxies. */ endpoints?: Partial; fetchImpl?: FetchLike; now?: () => number; randomBytes?: (size: number) => Uint8Array; /** Refresh this many milliseconds before expiry. Default: 60 seconds. */ refreshSkewMs?: number; /** End-to-end timeout for token exchange and refresh. Default: 30 seconds. */ timeoutMs?: number; /** Maximum OAuth response body size. Default: 1 MiB. */ maxResponseSizeBytes?: number; /** Receives safe OAuth lifecycle diagnostics. Default: silent. */ logger?: Logger; onDiagnostic?: DiagnosticListener; }" + "declaration": "export interface OAuthAuthOptions { client: OAuthClient; /** Optional when the caller only needs authorization URL and code exchange. */ tokenStore?: OAuthTokenStore; /** Optional short-lived store for transactions that span requests or pages. */ authorizationTransactionStore?: OAuthAuthorizationTransactionStore; /** OAuth environment. Required to prevent accidental live authorization. */ env: Environment; /** HTTPS OAuth endpoint overrides for tests, mocks, or proxies. */ endpoints?: Partial; fetchImpl?: FetchLike; now?: () => number; /** Cryptographically secure random source. Override only for deterministic tests. */ randomBytes?: (size: number) => Uint8Array; /** Refresh this many milliseconds before expiry. Default: 60 seconds. */ refreshSkewMs?: number; /** End-to-end timeout for token exchange and refresh. Default: 30 seconds. */ timeoutMs?: number; /** Maximum OAuth response body size. Default: 1 MiB. */ maxResponseSizeBytes?: number; /** Receives safe OAuth lifecycle diagnostics. Default: silent. */ logger?: Logger; onDiagnostic?: DiagnosticListener; }" }, { "kind": "ClassDeclaration", @@ -1468,6 +1518,11 @@ "name": "OAuthAuthorizationTransaction", "declaration": "export interface OAuthAuthorizationTransaction { state: string; /** Present only for public clients. Keep it private until the callback. */ codeVerifier?: string; }" }, + { + "kind": "InterfaceDeclaration", + "name": "OAuthAuthorizationTransactionStore", + "declaration": "export interface OAuthAuthorizationTransactionStore { /** Store this record with a short expiration and keep its verifier confidential. */ save(transaction: OAuthAuthorizationTransaction): Promise; /** Atomically return and delete the record for `state`; return undefined on replay or expiry. */ consume(state: string): Promise; }" + }, { "kind": "TypeAliasDeclaration", "name": "OAuthClient", @@ -1476,7 +1531,7 @@ { "kind": "InterfaceDeclaration", "name": "OAuthEndpoints", - "declaration": "export interface OAuthEndpoints { api: string; authorization: string; token: string; }" + "declaration": "export interface OAuthEndpoints { api: string; authorization: string; token: string; revocation: string; }" }, { "kind": "ClassDeclaration", @@ -1496,7 +1551,7 @@ { "kind": "InterfaceDeclaration", "name": "OAuthTokenStore", - "declaration": "export interface OAuthTokenStore { load(): Promise; save(tokens: T): Promise; clear(): Promise; /** * Atomically claim an authorization state. Return `true` only for the first * claim and retain the claim for the transaction's short lifetime. * Implement this durably when authorization transactions can cross process * or page boundaries. */ consumeAuthorizationState(state: string): Promise; /** * Clear the stored record only when it still uses `refreshToken`. * A store shared by processes must implement this as a real compare-and-swap operation. */ clearIfCurrent?(refreshToken: string): Promise; runExclusive(operation: () => Promise): Promise; }" + "declaration": "export interface OAuthTokenStore { load(): Promise; save(tokens: T): Promise; clear(): Promise; /** * Legacy compatibility hook for atomically claiming an authorization state. * Prefer `authorizationTransactionStore` when authorization transactions are * stored separately from tokens. */ consumeAuthorizationState?(state: string): Promise; /** * Clear the stored record only when it still uses `refreshToken`. * A store shared by processes must implement this as a real compare-and-swap operation. */ clearIfCurrent?(refreshToken: string): Promise; runExclusive(operation: () => Promise): Promise; }" }, { "kind": "TypeAliasDeclaration", @@ -1643,6 +1698,11 @@ "name": "PublicWebSocket", "declaration": "export type PublicWebSocket = Pick;" }, + { + "kind": "TypeAliasDeclaration", + "name": "RandomBytes", + "declaration": "export type RandomBytes = (size: number) => Uint8Array;" + }, { "kind": "ClassDeclaration", "name": "RateLimitError", diff --git a/packages/sdk-typescript/scripts/verify-package.mjs b/packages/sdk-typescript/scripts/verify-package.mjs index a132391..0d0f4bf 100644 --- a/packages/sdk-typescript/scripts/verify-package.mjs +++ b/packages/sdk-typescript/scripts/verify-package.mjs @@ -60,7 +60,7 @@ try { writeFileSync(join(temp, "package.json"), `{"type":"module","dependencies":{"@gemini-markets/sdk":"file:./${packed.filename}","@opentelemetry/api":"^1.9.0"}}`); execFileSync("npm", ["install", "--ignore-scripts", "--no-package-lock", "--cache", join(temp, ".npm")], { cwd: temp, stdio: "inherit" }); writeFileSync(join(temp, "consumer.mjs"), ` -import { createClient, MARKET_DATA_OPERATIONS, MARGIN_OPERATIONS, TRADING_OPERATIONS, PERPETUALS_OPERATIONS, ACCOUNT_OPERATIONS, STAKING_OPERATIONS, TRANSFERS_OPERATIONS, CLEARING_OPERATIONS, INSTANT_OPERATIONS } from "@gemini-markets/sdk/browser"; +import { createClient, BrowserOAuthAuth, BearerAuth, createPkceCodeChallenge, generatePkceCodeVerifier, MARKET_DATA_OPERATIONS, MARGIN_OPERATIONS, TRADING_OPERATIONS, PERPETUALS_OPERATIONS, ACCOUNT_OPERATIONS, STAKING_OPERATIONS, TRANSFERS_OPERATIONS, CLEARING_OPERATIONS, INSTANT_OPERATIONS } from "@gemini-markets/sdk/browser"; import * as browserExports from "@gemini-markets/sdk/browser"; import { HmacAuth, createClient as createServerClient } from "@gemini-markets/sdk/server"; import { trace } from "@opentelemetry/api"; @@ -74,6 +74,12 @@ try { } if (!bareImportRejected) throw new Error("bare package import must require an explicit runtime entry point"); +const packedVerifier = generatePkceCodeVerifier((size) => new Uint8Array(size).fill(7)); +if (!(await createPkceCodeChallenge(packedVerifier)).match(/^[A-Za-z0-9_-]{43}$/)) throw new Error("packed PKCE helpers are unavailable or invalid"); +const packedBearer = new BearerAuth({ accessToken: "packed-token" }); +if ((await packedBearer.credentialHeaders("")).Authorization !== "Bearer packed-token") throw new Error("packed Bearer auth is unavailable or invalid"); +new BrowserOAuthAuth({ env: "sandbox", client: { type: "public", clientId: "packed-client", redirectUri: "https://app.example.com/callback" } }); + const client = await createClient({ env: "sandbox" }); if (!client.marketData || !client.trading || !client.margin || !client.perpetuals || !client.account || !client.staking || !client.transfers || !client.clearing || !client.instant || !client.predictions || !client.websocket) { throw new Error("missing client domain surfaces"); diff --git a/packages/sdk-typescript/src/auth/bearer.test.ts b/packages/sdk-typescript/src/auth/bearer.test.ts new file mode 100644 index 0000000..004f946 --- /dev/null +++ b/packages/sdk-typescript/src/auth/bearer.test.ts @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { BearerAuth, SdkError } from "../browser/index.js"; + +test("BearerAuth supplies only an Authorization header", async () => { + const auth = new BearerAuth({ accessToken: "access-token" }); + + assert.equal(auth.nextNonce(), undefined); + assert.deepEqual(await auth.credentialHeaders("ignored"), { + Authorization: "Bearer access-token", + }); + assert.deepEqual(Object.keys(auth), []); +}); + +test("BearerAuth rejects an empty access token", () => { + assert.throws( + () => new BearerAuth({ accessToken: "" }), + (error: unknown) => error instanceof SdkError && error.message === "accessToken is required", + ); +}); + +test("BearerAuth rejects header-control characters in access tokens", () => { + assert.throws( + () => new BearerAuth({ accessToken: "access-token\r\nX-Evil: injected" }), + (error: unknown) => error instanceof SdkError && /visible ASCII/.test(error.message), + ); +}); diff --git a/packages/sdk-typescript/src/auth/bearer.ts b/packages/sdk-typescript/src/auth/bearer.ts new file mode 100644 index 0000000..a47fcec --- /dev/null +++ b/packages/sdk-typescript/src/auth/bearer.ts @@ -0,0 +1,38 @@ +import type { AuthStrategy } from "../transport/http.js"; +import { SdkError } from "../errors.js"; +import { isBoundaryObject, isBoundaryString } from "../utils/boundary-value.js"; +import { validateOAuthToken } from "./token-values.js"; + +export interface BearerAuthOptions { + accessToken: string; +} + +/** + * Authenticate requests with an application-managed access token. + * This strategy does not persist or refresh the token. + */ +export class BearerAuth implements AuthStrategy { + readonly authCapability!: "bearer"; + readonly #accessToken: string; + + constructor(options: BearerAuthOptions) { + Object.defineProperty(this, "authCapability", { + value: "bearer", + enumerable: false, + configurable: false, + writable: false, + }); + if (!isBoundaryObject(options) || !isBoundaryString(options.accessToken) || options.accessToken.length === 0) { + throw new SdkError("accessToken is required"); + } + this.#accessToken = validateOAuthToken(options.accessToken, "accessToken"); + } + + nextNonce(): undefined { + return undefined; + } + + async credentialHeaders(_payloadBase64: string): Promise> { + return { Authorization: `Bearer ${this.#accessToken}` }; + } +} diff --git a/packages/sdk-typescript/src/auth/browser-oauth.test.ts b/packages/sdk-typescript/src/auth/browser-oauth.test.ts index 39fe99e..1f38e65 100644 --- a/packages/sdk-typescript/src/auth/browser-oauth.test.ts +++ b/packages/sdk-typescript/src/auth/browser-oauth.test.ts @@ -3,6 +3,7 @@ import { test } from "node:test"; import { BrowserOAuthAuth, + BearerAuth, type BrowserGeminiMarkets, type BrowserWebSocket, createClient, @@ -114,6 +115,15 @@ test("browser createClient accepts BrowserOAuthAuth", () => { client.close(); }); +test("browser createClient accepts application-managed BearerAuth", () => { + const client = createClient({ + env: "sandbox", + auth: new BearerAuth({ accessToken: "access-token" }), + }); + assert.ok(client); + client.close(); +}); + test("browser createClient rejects server and unknown auth strategies at runtime", () => { assert.throws( () => createClient(invalidBrowserClientOptions({ env: "sandbox", auth: new HmacAuth({ apiKey: "key", apiSecret: "secret" }) })), diff --git a/packages/sdk-typescript/src/auth/oauth.test.ts b/packages/sdk-typescript/src/auth/oauth.test.ts index 646d064..b18a87e 100644 --- a/packages/sdk-typescript/src/auth/oauth.test.ts +++ b/packages/sdk-typescript/src/auth/oauth.test.ts @@ -10,6 +10,7 @@ import { SdkError, serializeError, type FetchLike, + type OAuthAuthorizationTransactionStore, type OAuthTokenStore, type OAuthTokens, } from "../server/index.js"; @@ -90,6 +91,20 @@ class NonReentrantTokenStore extends MemoryTokenStore { } } +class MemoryAuthorizationTransactionStore implements OAuthAuthorizationTransactionStore { + readonly records = new Map(); + + async save(transaction: { state: string; codeVerifier?: string }): Promise { + this.records.set(transaction.state, { ...transaction }); + } + + async consume(state: string): Promise<{ state: string; codeVerifier?: string } | undefined> { + const transaction = this.records.get(state); + this.records.delete(state); + return transaction; + } +} + const validTokens = (overrides: Partial = {}): OAuthTokens => ({ accessToken: "access-1", refreshToken: "refresh-1", @@ -99,7 +114,7 @@ const validTokens = (overrides: Partial = {}): OAuthTokens => ({ ...overrides, }); -const publicOptions = (store: OAuthTokenStore, extra: BoundaryRecord = {}) => ({ +const publicOptions = (store: OAuthTokenStore | undefined, extra: BoundaryRecord = {}) => ({ env: "sandbox" as const, client: { type: "public" as const, @@ -140,6 +155,131 @@ void test("public authorization request generates state and S256 PKCE", async () assert.doesNotMatch(parsed.searchParams.get("code_challenge") ?? "", /=/); }); +void test("authorization URL and code exchange can run without token persistence", async () => { + let saved = false; + const auth = new OAuthAuth({ + ...publicOptions(undefined), + tokenStore: undefined, + fetchImpl: async () => { + saved = true; + return jsonResponse(200, { + access_token: "access-2", + refresh_token: "refresh-2", + token_type: "bearer", + scope: "orders:read", + expires_in: 3600, + }); + }, + }); + const { transaction } = await auth.beginAuthorization(["orders:read"]); + const callback = new URL("http://127.0.0.1:51234/callback"); + callback.searchParams.set("code", "authorization-code"); + callback.searchParams.set("state", transaction.state); + + const tokens = await auth.completeAuthorization(callback, transaction); + + assert(saved); + assert.equal(tokens.accessToken, "access-2"); + await assert.rejects( + auth.credentialHeaders("ignored"), + /tokenStore is required for authenticated client requests/, + ); +}); + +void test("protocol-only authorization rejects a transaction that was never begun", async () => { + const auth = new OAuthAuth({ + ...publicOptions(undefined), + tokenStore: undefined, + }); + const callback = new URL("http://127.0.0.1:51234/callback"); + callback.searchParams.set("code", "authorization-code"); + callback.searchParams.set("state", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); + + await assert.rejects( + auth.completeAuthorization(callback, { + state: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + codeVerifier: "A".repeat(43), + }), + /already been used/, + ); +}); + +void test("authorization transactions can be stored separately from OAuth tokens", async () => { + const transactionStore = new MemoryAuthorizationTransactionStore(); + const tokenStore = new MemoryTokenStore(); + const auth = new OAuthAuth(publicOptions(tokenStore, { + authorizationTransactionStore: transactionStore, + fetchImpl: async () => jsonResponse(200, { + access_token: "access-2", + refresh_token: "refresh-2", + token_type: "bearer", + scope: "orders:read", + expires_in: 3600, + }), + })); + const request = await auth.beginAuthorization(["orders:read"]); + assert.deepEqual(transactionStore.records.get(request.transaction.state), request.transaction); + + const callback = new URL("http://127.0.0.1:51234/callback"); + callback.searchParams.set("code", "authorization-code"); + callback.searchParams.set("state", request.transaction.state); + const tokens = await auth.completeAuthorization(callback); + + assert.equal(tokens.accessToken, "access-2"); + assert.equal(transactionStore.records.has(request.transaction.state), false); + assert.equal(tokenStore.record?.accessToken, "access-2"); + await assert.rejects(auth.completeAuthorization(callback), OAuthStateError); +}); + +void test("authorization transaction storage takes precedence over the legacy token-store state hook", async () => { + const transactionStore = new MemoryAuthorizationTransactionStore(); + const tokenStore = new MemoryTokenStore(); + const auth = new OAuthAuth(publicOptions(tokenStore, { + authorizationTransactionStore: transactionStore, + fetchImpl: async () => jsonResponse(200, { + access_token: "access-2", + refresh_token: "refresh-2", + token_type: "bearer", + scope: "orders:read", + expires_in: 3600, + }), + })); + const request = await auth.beginAuthorization(["orders:read"]); + const callback = new URL("http://127.0.0.1:51234/callback"); + callback.searchParams.set("code", "authorization-code"); + callback.searchParams.set("state", request.transaction.state); + + await auth.completeAuthorization(callback, request.transaction); + await assert.rejects(auth.completeAuthorization(callback), OAuthStateError); + assert.equal(transactionStore.records.has(request.transaction.state), false); +}); + +void test("durable authorization storage is authoritative for the PKCE verifier", async () => { + const transactionStore = new MemoryAuthorizationTransactionStore(); + let exchanges = 0; + const auth = new OAuthAuth(publicOptions(new MemoryTokenStore(), { + authorizationTransactionStore: transactionStore, + fetchImpl: async () => { + exchanges++; + return jsonResponse(200, {}); + }, + })); + const request = await auth.beginAuthorization(["orders:read"]); + const callback = new URL("http://127.0.0.1:51234/callback"); + callback.searchParams.set("code", "authorization-code"); + callback.searchParams.set("state", request.transaction.state); + + await assert.rejects( + auth.completeAuthorization(callback, { + state: request.transaction.state, + codeVerifier: "A".repeat(43), + }), + (error: BoundaryValue) => error instanceof OAuthStateError && /does not match the stored transaction/.test(error.message), + ); + assert.equal(exchanges, 0); + assert.equal(transactionStore.records.has(request.transaction.state), false); +}); + void test("OAuth rejects non-string scopes at runtime", async () => { const auth = new OAuthAuth(publicOptions(new MemoryTokenStore())); for (const scopes of [[123], [null]]) { @@ -250,6 +390,38 @@ void test("OAuth callback rejects custom-scheme component and query mismatches", } }); +void test("OAuth callback rejects duplicate response parameters", async () => { + let exchanges = 0; + const auth = new OAuthAuth(publicOptions(new MemoryTokenStore(), { + fetchImpl: async () => { + exchanges++; + return jsonResponse(200, {}); + }, + })); + const { transaction } = await auth.beginAuthorization(["orders:read"]); + const callback = new URL("http://127.0.0.1:51234/callback"); + callback.searchParams.set("code", "authorization-code"); + callback.searchParams.set("state", transaction.state); + callback.searchParams.append("state", transaction.state); + + await assert.rejects(auth.completeAuthorization(callback, transaction), OAuthStateError); + assert.equal(exchanges, 0); +}); + +void test("OAuth callback rejects a response containing both code and error", async () => { + const auth = new OAuthAuth(publicOptions(new MemoryTokenStore())); + const { transaction } = await auth.beginAuthorization(["orders:read"]); + const callback = new URL("http://127.0.0.1:51234/callback"); + callback.searchParams.set("code", "authorization-code"); + callback.searchParams.set("error", "access_denied"); + callback.searchParams.set("state", transaction.state); + + await assert.rejects( + auth.completeAuthorization(callback, transaction), + (error: BoundaryValue) => error instanceof OAuthAuthorizationError && error.error === "invalid_response", + ); +}); + void test("sandbox OAuth uses sandbox authorization, exchange, and refresh endpoints", async () => { const store = new MemoryTokenStore(); let tokenUrl: string | undefined; @@ -653,6 +825,42 @@ void test("OAuth rejects malformed redirect URIs and authorization transactions" ); }); +void test("OAuth maps malformed callback URLs to OAuthStateError", async () => { + const auth = new OAuthAuth(publicOptions(new MemoryTokenStore())); + + await assert.rejects( + auth.completeAuthorization("not a URL"), + (error: BoundaryValue) => error instanceof OAuthStateError && /valid URL/.test(error.message), + ); +}); + +void test("OAuth rejects unsafe redirect and endpoint URLs", () => { + for (const redirectUri of [ + "javascript:alert(1)", + "https://user:password@example.com/callback", + "http://app.example/callback", + ]) { + assert.throws( + () => new OAuthAuth(publicOptions(new MemoryTokenStore(), { + client: { type: "public", clientId: "client", redirectUri }, + })), + SdkError, + ); + } + assert.throws( + () => new OAuthAuth(publicOptions(new MemoryTokenStore(), { env: "invalid" })), + (error: BoundaryValue) => error instanceof SdkError && /sandbox|production/.test(error.message), + ); + for (const name of ["api", "authorization", "token"] as const) { + assert.throws( + () => new OAuthAuth(publicOptions(new MemoryTokenStore(), { + endpoints: { [name]: "http://localhost/oauth" }, + })), + (error: BoundaryValue) => error instanceof SdkError && /HTTPS/.test(error.message), + ); + } +}); + void test("OAuthAuth supplies Bearer auth through HttpTransport without HMAC or nonce", async () => { let captured: Parameters[1] | undefined; const auth = new OAuthAuth(publicOptions(new MemoryTokenStore(validTokens()))); @@ -871,12 +1079,44 @@ void test("revocation uses the current token and clears tokens only after succes await auth.revoke(); - assert.equal(request?.[0], "https://api.sandbox.gemini.com/v1/oauth/revokeByToken"); - assert.equal(request?.[1].headers.Authorization, "Bearer access-1"); + assert.equal(request?.[0], "https://exchange.sandbox.gemini.com/auth/token/revoke"); + assert.deepEqual(JSON.parse(request?.[1].body ?? "{}"), { + client_id: "public-client", + token: "access-1", + }); + assert.equal(request?.[1].headers.Authorization, undefined); assert.equal(request?.[1].redirect, "manual"); assert.equal(store.record, undefined); }); +void test("confidential revocation authenticates with the client secret", async () => { + const store = new MemoryTokenStore(validTokens()); + let body: BoundaryRecord = {}; + const auth = new OAuthAuth({ + env: "sandbox", + client: { + type: "confidential", + clientId: "server-client", + clientSecret: "server-secret", + redirectUri: "https://client.example/callback", + }, + tokenStore: store, + fetchImpl: async (_url, init) => { + body = JSON.parse(init.body ?? "{}"); + return jsonResponse(200, {}); + }, + }); + + await auth.revoke(); + + assert.deepEqual(body, { + client_id: "server-client", + client_secret: "server-secret", + token: "access-1", + }); + assert.equal(store.record, undefined); +}); + void test("OAuth revocation rejects redirects before reading the response body", async () => { for (const response of [ ...[300, 301, 302, 303, 304, 305, 306, 307, 308].map((status) => ({ status })), @@ -927,7 +1167,10 @@ void test("revocation uses the stored token without refreshing it", async () => fetchImpl: async (_url: string, init: Parameters[1]) => { refreshCalls++; revokeCalls++; - assert.equal(init.headers.Authorization, "Bearer short-access"); + assert.deepEqual(JSON.parse(init.body ?? "{}"), { + client_id: "public-client", + token: "short-access", + }); return jsonResponse(200, {}); }, })); @@ -943,7 +1186,10 @@ void test("revocation does not re-enter the token-store lock when the clock cros const store = new NonReentrantTokenStore(validTokens({ expiresAt: 1_700_000_000_001 })); const auth = new OAuthAuth(publicOptions(store, { fetchImpl: async (_url: string, init: Parameters[1]) => { - assert.equal(init.headers.Authorization, "Bearer access-1"); + assert.deepEqual(JSON.parse(init.body ?? "{}"), { + client_id: "public-client", + token: "access-1", + }); return jsonResponse(200, {}); }, })); @@ -1027,6 +1273,8 @@ void test("malformed persisted tokens fail as SdkError before any network reques [], validTokens({ accessToken: "" }), validTokens({ refreshToken: "" }), + validTokens({ accessToken: "access-token\r\nX-Evil: injected" }), + validTokens({ refreshToken: "refresh-token\n" }), { ...validTokens(), tokenType: "basic" }, { ...validTokens(), scope: 7 }, validTokens({ expiresAt: -1 }), diff --git a/packages/sdk-typescript/src/auth/oauth.ts b/packages/sdk-typescript/src/auth/oauth.ts index 975879a..840d457 100644 --- a/packages/sdk-typescript/src/auth/oauth.ts +++ b/packages/sdk-typescript/src/auth/oauth.ts @@ -9,10 +9,23 @@ import { SdkError, serializeError, } from "../errors.js"; -import { createResponseMetadata, type DiagnosticEvent, type DiagnosticListener, type ResponseMetadata } from "../observability/diagnostics.js"; +import { + createResponseMetadata, + sanitizeDiagnosticUrl, + type DiagnosticEvent, + type DiagnosticListener, + type ResponseMetadata, +} from "../observability/diagnostics.js"; import { emitDiagnostic, type Logger, NOOP_LOGGER } from "../observability/logging.js"; import type { Environment } from "../types/client.js"; import { toBase64, toBase64Url } from "../utils/encoding.js"; +import { + createPkceCodeChallenge, + generatePkceCodeVerifier, + isValidPkceCodeVerifier, + type RandomBytes, +} from "./pkce.js"; +import { validateOAuthToken } from "./token-values.js"; import { isBoundaryFunction, isBoundaryNumber, @@ -22,9 +35,30 @@ import { type BoundaryValue, } from "../utils/boundary-value.js"; -const REVOKE_PATH = "/v1/oauth/revokeByToken"; const DEFAULT_REFRESH_SKEW_MS = 60_000; const DEFAULT_MAX_OAUTH_RESPONSE_SIZE_BYTES = 1 * 1024 * 1024; +const MAX_OAUTH_STATE_LENGTH = 256; +const LOCAL_AUTHORIZATION_STATE_TTL_MS = 10 * 60_000; +const MAX_LOCAL_AUTHORIZATION_STATES = 1_024; +const UNSAFE_REDIRECT_PROTOCOLS = new Set([ + "about:", + "blob:", + "chrome-extension:", + "chrome:", + "data:", + "file:", + "ftp:", + "ftps:", + "intent:", + "javascript:", + "mailto:", + "tel:", + "urn:", + "vbscript:", + "ws:", + "wss:", +]); +const OAUTH_STATE_PATTERN = new RegExp(`^[A-Za-z0-9_-]{43,${MAX_OAUTH_STATE_LENGTH}}$`); const OAUTH_CALLBACK_RESPONSE_PARAMETERS = new Set([ "code", "state", @@ -38,6 +72,7 @@ export interface OAuthEndpoints { api: string; authorization: string; token: string; + revocation: string; } export const DEFAULT_OAUTH_ENDPOINTS = { @@ -45,11 +80,13 @@ export const DEFAULT_OAUTH_ENDPOINTS = { api: "https://api.gemini.com", authorization: "https://exchange.gemini.com/auth", token: "https://exchange.gemini.com/auth/token", + revocation: "https://exchange.gemini.com/auth/token/revoke", }, sandbox: { api: "https://api.sandbox.gemini.com", authorization: "https://exchange.sandbox.gemini.com/auth", token: "https://exchange.sandbox.gemini.com/auth/token", + revocation: "https://exchange.sandbox.gemini.com/auth/token/revoke", }, } satisfies Record; @@ -77,20 +114,25 @@ export interface OAuthTokens { } /** - * Store OAuth tokens. - * `runExclusive` must serialize operations for all OAuthAuth instances that use the store. + * Application-owned OAuth token storage adapter. + * + * The SDK does not select or manage the underlying storage medium. An + * application may back this interface with a keychain, encrypted file, + * database, browser secure-storage abstraction, or another store. The + * implementation is responsible for serialization and protecting the token + * values. `runExclusive` must serialize operations for all OAuthAuth instances + * that use the same store. */ export interface OAuthTokenStore { load(): Promise; save(tokens: T): Promise; clear(): Promise; /** - * Atomically claim an authorization state. Return `true` only for the first - * claim and retain the claim for the transaction's short lifetime. - * Implement this durably when authorization transactions can cross process - * or page boundaries. + * Legacy compatibility hook for atomically claiming an authorization state. + * Prefer `authorizationTransactionStore` when authorization transactions are + * stored separately from tokens. */ - consumeAuthorizationState(state: string): Promise; + consumeAuthorizationState?(state: string): Promise; /** * Clear the stored record only when it still uses `refreshToken`. * A store shared by processes must implement this as a real compare-and-swap operation. @@ -110,15 +152,30 @@ export interface OAuthAuthorizationRequest { transaction: OAuthAuthorizationTransaction; } +/** + * Store short-lived authorization transactions independently of OAuth tokens. + * `consume` must atomically return a transaction only once. + */ +export interface OAuthAuthorizationTransactionStore { + /** Store this record with a short expiration and keep its verifier confidential. */ + save(transaction: OAuthAuthorizationTransaction): Promise; + /** Atomically return and delete the record for `state`; return undefined on replay or expiry. */ + consume(state: string): Promise; +} + export interface OAuthAuthOptions { client: OAuthClient; - tokenStore: OAuthTokenStore; + /** Optional when the caller only needs authorization URL and code exchange. */ + tokenStore?: OAuthTokenStore; + /** Optional short-lived store for transactions that span requests or pages. */ + authorizationTransactionStore?: OAuthAuthorizationTransactionStore; /** OAuth environment. Required to prevent accidental live authorization. */ env: Environment; - /** OAuth endpoint overrides for tests, mocks, or proxies. */ + /** HTTPS OAuth endpoint overrides for tests, mocks, or proxies. */ endpoints?: Partial; fetchImpl?: FetchLike; now?: () => number; + /** Cryptographically secure random source. Override only for deterministic tests. */ randomBytes?: (size: number) => Uint8Array; /** Refresh this many milliseconds before expiry. Default: 60 seconds. */ refreshSkewMs?: number; @@ -138,6 +195,70 @@ function requiredString(value: BoundaryValue, name: string): string { return value; } +function validateRedirectUri(value: string): string { + let redirect: URL; + try { + redirect = new URL(value); + } catch { + throw new SdkError("redirectUri must be a valid URL"); + } + if (UNSAFE_REDIRECT_PROTOCOLS.has(redirect.protocol)) { + throw new SdkError("redirectUri must use a safe URL scheme"); + } + if (redirect.username || redirect.password) { + throw new SdkError("redirectUri must not contain URL credentials"); + } + if (redirect.protocol === "http:" && !isLoopbackHost(redirect.hostname)) { + throw new SdkError("non-loopback redirectUri must use HTTPS"); + } + return value; +} + +function isLoopbackHost(hostname: string): boolean { + const normalized = hostname.toLowerCase(); + if (normalized === "localhost" || normalized === "[::1]" || normalized === "::1") return true; + const octets = normalized.split("."); + return octets.length === 4 && octets[0] === "127" && + octets.slice(1).every((octet) => /^\d{1,3}$/.test(octet) && Number(octet) <= 255); +} + +function validateHttpsEndpoint(value: BoundaryValue, name: string): string { + const endpoint = requiredString(value, name); + let parsed: URL; + try { + parsed = new URL(endpoint); + } catch { + throw new SdkError(`${name} must be a valid URL`); + } + if (parsed.protocol !== "https:") { + throw new SdkError(`${name} must use HTTPS`); + } + if (parsed.username || parsed.password || parsed.hash) { + throw new SdkError(`${name} must not contain URL credentials or a fragment`); + } + return endpoint; +} + +function createAuthorizationState(randomBytes: (size: number) => Uint8Array): string { + const bytes = randomBytes(32); + if (!(bytes instanceof Uint8Array) || bytes.length < 32) { + throw new SdkError("randomBytes must return at least 32 bytes for OAuth state"); + } + const state = toBase64Url(bytes); + if (!OAUTH_STATE_PATTERN.test(state)) { + throw new SdkError("generated OAuth state is invalid"); + } + return state; +} + +function parseCallback(callback: string | URL): URL { + try { + return callback instanceof URL ? callback : new URL(callback); + } catch { + throw new OAuthStateError("OAuth callback is not a valid URL"); + } +} + function callbackMatchesRedirect(url: URL, redirect: URL): boolean { if ( url.protocol !== redirect.protocol || @@ -148,6 +269,10 @@ function callbackMatchesRedirect(url: URL, redirect: URL): boolean { url.hash !== redirect.hash ) return false; + for (const name of OAUTH_CALLBACK_RESPONSE_PARAMETERS) { + if (url.searchParams.getAll(name).length > 1) return false; + } + const configured = new Map>(); for (const [name, value] of redirect.searchParams) { const values = configured.get(name) ?? new Map(); @@ -176,7 +301,9 @@ function callbackMatchesRedirect(url: URL, redirect: URL): boolean { function isAuthorizationTransaction(value: BoundaryValue): value is OAuthAuthorizationTransaction { return isBoundaryObject(value) && isBoundaryString(value.state) && - (value.codeVerifier === undefined || isBoundaryString(value.codeVerifier)); + OAUTH_STATE_PATTERN.test(value.state) && + (value.codeVerifier === undefined || + (isBoundaryString(value.codeVerifier) && isValidPkceCodeVerifier(value.codeVerifier))); } function validateStoredTokens(tokens: BoundaryValue): OAuthTokens | undefined { @@ -187,8 +314,8 @@ function validateStoredTokens(tokens: BoundaryValue): OAuthTokens | undefined { throw new SdkError("stored OAuth tokens must be an object"); } const record = tokens; - const accessToken = requiredString(record.accessToken, "stored OAuth accessToken"); - const refreshToken = requiredString(record.refreshToken, "stored OAuth refreshToken"); + const accessToken = validateOAuthToken(record.accessToken, "stored OAuth accessToken"); + const refreshToken = validateOAuthToken(record.refreshToken, "stored OAuth refreshToken"); if (record.tokenType !== "bearer") { throw new SdkError("stored OAuth tokenType must be bearer"); } @@ -212,7 +339,8 @@ export class OAuthAuth implements AuthStrategy { /** Runtime marker for server OAuth. BrowserOAuthAuth narrows this value. */ readonly authCapability!: "server" | "browser"; readonly #client: OAuthClient; - readonly #tokenStore: OAuthTokenStore; + readonly #tokenStore?: OAuthTokenStore; + readonly #authorizationTransactionStore?: OAuthAuthorizationTransactionStore; readonly #endpoints: OAuthEndpoints; readonly #fetchImpl: FetchLike; readonly #now: () => number; @@ -223,7 +351,8 @@ export class OAuthAuth implements AuthStrategy { readonly #logger: Logger; readonly #onDiagnostic?: DiagnosticListener; readonly #runExclusive: (operation: () => Promise) => Promise; - readonly #consumeAuthorizationState: (state: string) => Promise; + readonly #legacyConsumeAuthorizationState?: (state: string) => Promise; + readonly #pendingAuthorizationStates = new Map(); constructor(options: OAuthAuthOptions) { Object.defineProperty(this, "authCapability", { @@ -240,21 +369,23 @@ export class OAuthAuth implements AuthStrategy { } requiredString(options.client.clientId, "clientId"); requiredString(options.client.redirectUri, "redirectUri"); - try { - new URL(options.client.redirectUri); - } catch { - throw new SdkError("redirectUri must be a valid URL"); - } + validateRedirectUri(options.client.redirectUri); if (options.client.type === "confidential") { requiredString(options.client.clientSecret, "clientSecret"); } const tokenStore = options.tokenStore; const tokenStoreRecord: BoundaryRecord = isBoundaryObject(tokenStore) ? tokenStore : {}; const { load, save, clear, consumeAuthorizationState, runExclusive } = tokenStoreRecord; - if (!isBoundaryFunction(load) || !isBoundaryFunction(save) || - !isBoundaryFunction(clear) || !isBoundaryFunction(consumeAuthorizationState) || - !isBoundaryFunction(runExclusive)) { - throw new SdkError("tokenStore must implement load, save, clear, consumeAuthorizationState, and runExclusive"); + if (tokenStore !== undefined && (!isBoundaryFunction(load) || !isBoundaryFunction(save) || + !isBoundaryFunction(clear) || !isBoundaryFunction(runExclusive))) { + throw new SdkError("tokenStore must implement load, save, clear, and runExclusive"); + } + const transactionStore = options.authorizationTransactionStore; + const transactionStoreRecord: BoundaryRecord = isBoundaryObject(transactionStore) ? transactionStore : {}; + const { save: saveTransaction, consume: consumeTransaction } = transactionStoreRecord; + if (transactionStore !== undefined && + (!isBoundaryFunction(saveTransaction) || !isBoundaryFunction(consumeTransaction))) { + throw new SdkError("authorizationTransactionStore must implement save and consume"); } const skew = options.refreshSkewMs ?? DEFAULT_REFRESH_SKEW_MS; if (!Number.isFinite(skew) || skew < 0) { @@ -270,15 +401,23 @@ export class OAuthAuth implements AuthStrategy { throw new SdkError("randomBytes must be a function"); } this.#client = { ...options.client }; - this.#tokenStore = options.tokenStore; - this.#runExclusive = tokenStore.runExclusive.bind(tokenStore); - this.#consumeAuthorizationState = tokenStore.consumeAuthorizationState.bind(tokenStore); - if (!options.env) throw new SdkError("env is required; choose \"sandbox\" or \"production\""); + this.#tokenStore = tokenStore; + this.#authorizationTransactionStore = transactionStore; + this.#runExclusive = tokenStore === undefined + ? async (operation: () => Promise) => operation() + : tokenStore.runExclusive.bind(tokenStore); + this.#legacyConsumeAuthorizationState = isBoundaryFunction(consumeAuthorizationState) + ? (consumeAuthorizationState as (state: string) => Promise).bind(tokenStore) + : undefined; + if (options.env !== "sandbox" && options.env !== "production") { + throw new SdkError("env is required; choose \"sandbox\" or \"production\""); + } const defaults = DEFAULT_OAUTH_ENDPOINTS[options.env]; this.#endpoints = { - api: options.endpoints?.api ?? defaults.api, - authorization: options.endpoints?.authorization ?? defaults.authorization, - token: options.endpoints?.token ?? defaults.token, + api: validateHttpsEndpoint(options.endpoints?.api ?? defaults.api, "endpoints.api"), + authorization: validateHttpsEndpoint(options.endpoints?.authorization ?? defaults.authorization, "endpoints.authorization"), + token: validateHttpsEndpoint(options.endpoints?.token ?? defaults.token, "endpoints.token"), + revocation: validateHttpsEndpoint(options.endpoints?.revocation ?? defaults.revocation, "endpoints.revocation"), }; // SAFETY: The platform fetch response is adapted to the SDK's deliberately smaller FetchLike contract. this.#fetchImpl = options.fetchImpl ?? @@ -320,7 +459,7 @@ export class OAuthAuth implements AuthStrategy { scopes.some((scope) => !isBoundaryString(scope) || scope.length === 0)) { throw new SdkError("scopes must contain at least one non-empty scope"); } - const state = toBase64Url(this.#randomBytes(32)); + const state = createAuthorizationState(this.#randomBytes); const params = new URLSearchParams({ client_id: this.#client.clientId, response_type: "code", @@ -331,29 +470,27 @@ export class OAuthAuth implements AuthStrategy { const transaction: OAuthAuthorizationTransaction = { state }; if (this.#client.type === "public") { - const codeVerifier = toBase64Url(this.#randomBytes(64)); - if (!/^[A-Za-z0-9._~-]{43,128}$/.test(codeVerifier)) { - throw new SdkError("generated PKCE verifier must be 43-128 unreserved characters"); - } - const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(codeVerifier)); - const challenge = toBase64Url(new Uint8Array(hash)); + const codeVerifier = generatePkceCodeVerifier(this.#randomBytes as RandomBytes); + const challenge = await createPkceCodeChallenge(codeVerifier); transaction.codeVerifier = codeVerifier; params.set("code_challenge", challenge); params.set("code_challenge_method", "S256"); } - return { url: `${this.#endpoints.authorization}?${params}`, transaction }; + await this.#authorizationTransactionStore?.save(transaction); + this.#trackLocalAuthorizationState(transaction.state); + + const authorizationUrl = new URL(this.#endpoints.authorization); + for (const [name, value] of params) authorizationUrl.searchParams.set(name, value); + return { url: authorizationUrl.toString(), transaction }; } async completeAuthorization( callback: string | URL, - transaction: OAuthAuthorizationTransaction | undefined, + transaction?: OAuthAuthorizationTransaction, options: RequestOptions = {}, ): Promise { - const url = callback instanceof URL ? callback : new URL(callback); - if (!isAuthorizationTransaction(transaction)) { - throw new OAuthStateError("OAuth authorization transaction is invalid"); - } + const url = parseCallback(callback); const redirect = new URL(this.#client.redirectUri); if (!callbackMatchesRedirect(url, redirect)) { throw new OAuthStateError("OAuth callback does not match the configured redirect URI"); @@ -362,50 +499,103 @@ export class OAuthAuth implements AuthStrategy { if (!returnedState) { throw new OAuthStateError("OAuth callback is missing state"); } - if (!transaction?.state || returnedState !== transaction.state) { - throw new OAuthStateError("OAuth callback state does not match the authorization request"); - } - const callbackError = url.searchParams.get("error"); - if (callbackError) { - throw new OAuthAuthorizationError( - callbackError, - url.searchParams.get("error_description") ?? undefined, - ); + let resolvedTransaction = transaction; + let stateAlreadyConsumed = false; + if (this.#authorizationTransactionStore !== undefined) { + const storedTransaction = await this.#authorizationTransactionStore.consume(returnedState); + stateAlreadyConsumed = true; + if (storedTransaction === undefined) { + throw new OAuthStateError("OAuth authorization transaction has already been used"); + } + if (!isAuthorizationTransaction(storedTransaction)) { + throw new OAuthStateError("OAuth authorization transaction is invalid"); + } + if (resolvedTransaction !== undefined && + (!isAuthorizationTransaction(resolvedTransaction) || + resolvedTransaction.state !== storedTransaction.state || + resolvedTransaction.codeVerifier !== storedTransaction.codeVerifier)) { + throw new OAuthStateError("OAuth authorization transaction does not match the stored transaction"); + } + resolvedTransaction = storedTransaction; } - const code = url.searchParams.get("code"); - if (!code) { - throw new OAuthAuthorizationError("invalid_response", "OAuth callback is missing code"); + if (!isAuthorizationTransaction(resolvedTransaction)) { + throw new OAuthStateError("OAuth authorization transaction is invalid"); } - - const body: OAuthTokenRequest = { - client_id: this.#client.clientId, - code, - redirect_uri: this.#client.redirectUri, - grant_type: "authorization_code", - }; - if (this.#client.type === "public") { - if (!transaction.codeVerifier || - !/^[A-Za-z0-9._~-]{43,128}$/.test(transaction.codeVerifier)) { - throw new SdkError("public OAuth transaction is missing a valid PKCE verifier"); - } - body.code_verifier = transaction.codeVerifier; - } else { - body.client_secret = this.#client.clientSecret; + if (returnedState !== resolvedTransaction.state) { + throw new OAuthStateError("OAuth callback state does not match the authorization request"); } return this.#runExclusive(async () => { - if (!await this.#consumeAuthorizationState(transaction.state)) { + if (!stateAlreadyConsumed && !await this.#claimAuthorizationState(resolvedTransaction!.state)) { throw new OAuthStateError("OAuth authorization transaction has already been used"); } + const callbackError = url.searchParams.get("error"); + const code = url.searchParams.get("code"); + if (callbackError && code) { + throw new OAuthAuthorizationError("invalid_response", "OAuth callback contains both code and error"); + } + if (callbackError) { + throw new OAuthAuthorizationError( + callbackError, + url.searchParams.get("error_description") ?? undefined, + ); + } + if (!code) { + throw new OAuthAuthorizationError("invalid_response", "OAuth callback is missing code"); + } + + const body: OAuthTokenRequest = { + client_id: this.#client.clientId, + code, + redirect_uri: this.#client.redirectUri, + grant_type: "authorization_code", + }; + if (this.#client.type === "public") { + if (!resolvedTransaction!.codeVerifier) { + throw new SdkError("public OAuth transaction is missing a valid PKCE verifier"); + } + body.code_verifier = resolvedTransaction!.codeVerifier; + } else { + body.client_secret = this.#client.clientSecret; + } + // Claim before the token exchange so concurrent auth instances cannot // submit the same authorization code twice. A failed exchange requires // starting a fresh authorization flow. const tokens = await this.#tokenRequest(body, options); - await this.#tokenStore.save(tokens); + await this.#tokenStore?.save(tokens); return tokens; }); } + async #claimAuthorizationState(state: string): Promise { + if (this.#authorizationTransactionStore !== undefined) { + return (await this.#authorizationTransactionStore.consume(state)) !== undefined; + } + if (this.#legacyConsumeAuthorizationState !== undefined) { + return this.#legacyConsumeAuthorizationState(state); + } + const expiresAt = this.#pendingAuthorizationStates.get(state); + this.#pendingAuthorizationStates.delete(state); + return expiresAt !== undefined && expiresAt > this.#now(); + } + + #trackLocalAuthorizationState(state: string): void { + if (this.#authorizationTransactionStore !== undefined || + this.#legacyConsumeAuthorizationState !== undefined) return; + const now = this.#now(); + if (!Number.isFinite(now)) return; + for (const [pendingState, expiresAt] of this.#pendingAuthorizationStates) { + if (expiresAt <= now) this.#pendingAuthorizationStates.delete(pendingState); + } + this.#pendingAuthorizationStates.set(state, now + LOCAL_AUTHORIZATION_STATE_TTL_MS); + while (this.#pendingAuthorizationStates.size > MAX_LOCAL_AUTHORIZATION_STATES) { + const oldest = this.#pendingAuthorizationStates.keys().next().value; + if (oldest === undefined) break; + this.#pendingAuthorizationStates.delete(oldest); + } + } + nextNonce(): undefined { return undefined; } @@ -416,8 +606,9 @@ export class OAuthAuth implements AuthStrategy { } async revoke(options: RequestOptions = {}): Promise { + const tokenStore = this.#requireTokenStore(); await this.#runExclusive(async () => { - const current = validateStoredTokens(await this.#tokenStore.load()); + const current = validateStoredTokens(await tokenStore.load()); if (!current) return; try { await this.#revokeRequest(current.accessToken, options); @@ -429,20 +620,21 @@ export class OAuthAuth implements AuthStrategy { async #revokeRequest(accessToken: string, options: RequestOptions): Promise { const execution = deadline(options, this.#timeoutMs); - const payload = toBase64(JSON.stringify({ request: REVOKE_PATH })); + const requestBody: Record = { + client_id: this.#client.clientId, + token: accessToken, + }; + if (this.#client.type === "confidential") requestBody.client_secret = this.#client.clientSecret; let response: Awaited>; let text: string; try { - response = await withSignal(this.#fetchImpl(`${this.#endpoints.api}${REVOKE_PATH}`, { + response = await withSignal(this.#fetchImpl(this.#endpoints.revocation, { method: "POST", headers: { - Authorization: `Bearer ${accessToken}`, Accept: "application/json", - "Content-Length": "0", - "Content-Type": "text/plain", - "Cache-Control": "no-cache", - "X-GEMINI-PAYLOAD": payload, + "Content-Type": "application/json", }, + body: JSON.stringify(requestBody), signal: execution.signal, redirect: "manual", }), execution.signal); @@ -478,7 +670,8 @@ export class OAuthAuth implements AuthStrategy { } async #validTokens(options: RequestOptions = {}): Promise { - const tokens = validateStoredTokens(await this.#tokenStore.load()); + const tokenStore = this.#requireTokenStore(); + const tokens = validateStoredTokens(await tokenStore.load()); if (!tokens) { throw new SdkError("OAuth tokens are unavailable; complete authorization first"); } @@ -487,7 +680,7 @@ export class OAuthAuth implements AuthStrategy { } return this.#runExclusive(async () => { - const current = validateStoredTokens(await this.#tokenStore.load()); + const current = validateStoredTokens(await tokenStore.load()); if (!current) { throw new SdkError("OAuth tokens are unavailable; complete authorization first"); } @@ -500,6 +693,7 @@ export class OAuthAuth implements AuthStrategy { } async #refresh(current: OAuthTokens, options: RequestOptions = {}): Promise { + const tokenStore = this.#requireTokenStore(); const body: OAuthTokenRequest = { client_id: this.#client.clientId, refresh_token: current.refreshToken, @@ -511,7 +705,7 @@ export class OAuthAuth implements AuthStrategy { try { const tokens = await this.#tokenRequest(body, options); - await this.#tokenStore.save(tokens); + await tokenStore.save(tokens); return tokens; } catch (error) { if (error instanceof OAuthTokenError && error.error === "invalid_grant") { @@ -522,19 +716,27 @@ export class OAuthAuth implements AuthStrategy { } async #clearStoredTokens(refreshToken: string): Promise { - const clearIfCurrent = this.#tokenStore.clearIfCurrent; + const tokenStore = this.#requireTokenStore(); + const clearIfCurrent = tokenStore.clearIfCurrent; if (isBoundaryFunction(clearIfCurrent)) { - await clearIfCurrent.call(this.#tokenStore, refreshToken); + await clearIfCurrent.call(tokenStore, refreshToken); return; } - await this.#tokenStore.clear(); + await tokenStore.clear(); + } + + #requireTokenStore(): OAuthTokenStore { + if (this.#tokenStore === undefined) { + throw new SdkError("OAuth tokenStore is required for authenticated client requests"); + } + return this.#tokenStore; } async #tokenRequest(body: Record, options: RequestOptions = {}): Promise { const execution = deadline(options, this.#timeoutMs); const correlationId = crypto.randomUUID(); const metadata = (status?: number, response?: { headers?: { get(name: string): string | null } }): ResponseMetadata => - createResponseMetadata({ endpoint: this.#endpoints.token, method: "POST", correlationId, status, retryCount: 0, headers: response?.headers }); + createResponseMetadata({ endpoint: sanitizeDiagnosticUrl(this.#endpoints.token), method: "POST", correlationId, status, retryCount: 0, headers: response?.headers }); const eventName = body.grant_type === "refresh_token" ? "token.refresh" : "token.exchange"; this.#emit("debug", "token.request.start", metadata()); let response: Awaited>; @@ -596,8 +798,8 @@ export class OAuthAuth implements AuthStrategy { let tokens: OAuthTokens; try { - const accessToken = requiredString(parsed.access_token, "access_token"); - const refreshToken = requiredString(parsed.refresh_token, "refresh_token"); + const accessToken = validateOAuthToken(parsed.access_token, "access_token"); + const refreshToken = validateOAuthToken(parsed.refresh_token, "refresh_token"); const tokenType = requiredString(parsed.token_type, "token_type").toLowerCase(); if (tokenType !== "bearer") { throw new SdkError(`unsupported OAuth token_type ${tokenType}`); diff --git a/packages/sdk-typescript/src/auth/pkce.test.ts b/packages/sdk-typescript/src/auth/pkce.test.ts new file mode 100644 index 0000000..aae42a8 --- /dev/null +++ b/packages/sdk-typescript/src/auth/pkce.test.ts @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + createPkceCodeChallenge, + generatePkceCodeVerifier, + SdkError, +} from "../server/index.js"; + +test("PKCE helpers generate an RFC-compliant verifier", () => { + const verifier = generatePkceCodeVerifier((size) => new Uint8Array(size).fill(7)); + + assert.equal(verifier.length, 86); + assert.match(verifier, /^[A-Za-z0-9._~-]{43,128}$/); +}); + +test("PKCE helper derives the RFC 7636 S256 example challenge", async () => { + const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + + assert.equal( + await createPkceCodeChallenge(verifier), + "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + ); +}); + +test("PKCE helpers reject malformed verifiers and random sources", async () => { + await assert.rejects( + () => createPkceCodeChallenge("not-a-verifier"), + (error: unknown) => error instanceof SdkError && /code verifier/.test(error.message), + ); + assert.throws( + () => generatePkceCodeVerifier(() => "invalid" as never), + (error: unknown) => error instanceof SdkError && /Uint8Array/.test(error.message), + ); +}); diff --git a/packages/sdk-typescript/src/auth/pkce.ts b/packages/sdk-typescript/src/auth/pkce.ts new file mode 100644 index 0000000..fb14985 --- /dev/null +++ b/packages/sdk-typescript/src/auth/pkce.ts @@ -0,0 +1,39 @@ +import { SdkError } from "../errors.js"; +import { toBase64Url } from "../utils/encoding.js"; + +const CODE_VERIFIER_PATTERN = /^[A-Za-z0-9._~-]{43,128}$/; +const CODE_VERIFIER_BYTES = 64; + +export type RandomBytes = (size: number) => Uint8Array; + +function defaultRandomBytes(size: number): Uint8Array { + return crypto.getRandomValues(new Uint8Array(size)); +} + +/** Return whether a value is a syntactically valid RFC 7636 code verifier. */ +export function isValidPkceCodeVerifier(codeVerifier: string): boolean { + return typeof codeVerifier === "string" && CODE_VERIFIER_PATTERN.test(codeVerifier); +} + +function validateCodeVerifier(codeVerifier: string): string { + if (!isValidPkceCodeVerifier(codeVerifier)) { + throw new SdkError("PKCE code verifier must be 43-128 unreserved characters"); + } + return codeVerifier; +} + +/** Generate a high-entropy RFC 7636 code verifier. */ +export function generatePkceCodeVerifier(randomBytes: RandomBytes = defaultRandomBytes): string { + const bytes = randomBytes(CODE_VERIFIER_BYTES); + if (!(bytes instanceof Uint8Array)) { + throw new SdkError("PKCE random source must return a Uint8Array"); + } + return validateCodeVerifier(toBase64Url(bytes)); +} + +/** Derive the RFC 7636 S256 code challenge for a code verifier. */ +export async function createPkceCodeChallenge(codeVerifier: string): Promise { + const validated = validateCodeVerifier(codeVerifier); + const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(validated)); + return toBase64Url(new Uint8Array(hash)); +} diff --git a/packages/sdk-typescript/src/auth/token-values.ts b/packages/sdk-typescript/src/auth/token-values.ts new file mode 100644 index 0000000..8f6a1bf --- /dev/null +++ b/packages/sdk-typescript/src/auth/token-values.ts @@ -0,0 +1,16 @@ +import { SdkError } from "../errors.js"; +import { isBoundaryString, type BoundaryValue } from "../utils/boundary-value.js"; + +// OAuth token values are opaque, but they must not contain control characters +// before an access token is placed in an HTTP Authorization header. +const OAUTH_TOKEN_PATTERN = /^[\x21-\x7E]+$/; + +export function validateOAuthToken(value: BoundaryValue, name: string): string { + if (!isBoundaryString(value) || value.length === 0) { + throw new SdkError(`${name} is required`); + } + if (!OAUTH_TOKEN_PATTERN.test(value)) { + throw new SdkError(`${name} must contain only visible ASCII characters`); + } + return value; +} diff --git a/packages/sdk-typescript/src/browser/index.ts b/packages/sdk-typescript/src/browser/index.ts index 32c0a10..06ac29b 100644 --- a/packages/sdk-typescript/src/browser/index.ts +++ b/packages/sdk-typescript/src/browser/index.ts @@ -20,8 +20,11 @@ export type { } from "../transport/http.js"; export type { RestPromise } from "../transport/rest-promise.js"; -// Auth. OAuth only. No HMAC or confidential clients. +// Auth. OAuth and static Bearer only. No HMAC or confidential clients. +import { BearerAuth, type BearerAuthOptions } from "../auth/bearer.js"; +export { BearerAuth, type BearerAuthOptions }; +export { createPkceCodeChallenge, generatePkceCodeVerifier, type RandomBytes } from "../auth/pkce.js"; import { OAuthAuth as _OAuthAuth, DEFAULT_OAUTH_ENDPOINTS, @@ -31,6 +34,7 @@ import { type OAuthEndpoints, type OAuthTokens, type OAuthTokenStore, + type OAuthAuthorizationTransactionStore, } from "../auth/oauth.js"; import { SdkError } from "../errors.js"; @@ -67,6 +71,7 @@ export type { OAuthAuthorizationTransaction, OAuthTokens, OAuthTokenStore, + OAuthAuthorizationTransactionStore, OAuthEndpoints, }; export { DEFAULT_OAUTH_ENDPOINTS }; @@ -176,10 +181,10 @@ export { /** * Browser client options. * This entry point does not accept confidential or HMAC auth. - * Browser OAuth authenticates REST requests only. + * Browser OAuth and static Bearer auth authenticate REST requests only. */ export type BrowserClientOptions = Omit & { - auth?: BrowserOAuthAuth; + auth?: BrowserOAuthAuth | import("../auth/bearer.js").BearerAuth; }; /** Browser WebSocket surface. It contains only the public connection namespace. */ @@ -206,7 +211,7 @@ export type BrowserGeminiMarkets = BrowserGeminiMarketsImpl; * const client = createClient({ env: "sandbox", auth }); * ``` * - * Browser OAuth does not authenticate private WebSocket streams or request methods. + * Browser OAuth and Bearer auth do not authenticate private WebSocket streams or request methods. * Browser clients expose only `websocket.public`; private operations are not bundled. * Use the server entry point or a server-side relay for those operations. * Public WebSocket streams do not require authentication. @@ -214,8 +219,9 @@ export type BrowserGeminiMarkets = BrowserGeminiMarketsImpl; export function createClient(options: BrowserClientOptions): BrowserGeminiMarkets { if (!options?.env) throw new SdkError("env is required; choose \"sandbox\" or \"production\""); if (options.auth !== undefined && - (!(options.auth instanceof BrowserOAuthAuth) || options.auth.authCapability !== "browser")) { - throw new SdkError("Browser clients accept only BrowserOAuthAuth strategies"); + (!((options.auth instanceof BrowserOAuthAuth && options.auth.authCapability === "browser") || + options.auth instanceof BearerAuth))) { + throw new SdkError("Browser clients accept only BrowserOAuthAuth strategies or BearerAuth"); } return new BrowserGeminiMarketsImpl(options); } From 99c2942e6cdaa5ecffc3eccd29bc4e31450abb2d Mon Sep 17 00:00:00 2001 From: Andrew Fuller Date: Fri, 28 Aug 2026 20:44:55 -0400 Subject: [PATCH 2/4] fix(sdk-typescript): harden OAuth revocation lifecycle --- packages/sdk-typescript/README.md | 2 + .../scripts/api-surface.snapshot.json | 4 +- .../sdk-typescript/src/auth/oauth.test.ts | 114 +++++++++++------- packages/sdk-typescript/src/auth/oauth.ts | 51 ++++++-- .../src/observability/opentelemetry.test.ts | 36 ++++++ .../src/observability/opentelemetry.ts | 8 +- 6 files changed, 157 insertions(+), 58 deletions(-) diff --git a/packages/sdk-typescript/README.md b/packages/sdk-typescript/README.md index 526d4c4..9b19ca8 100644 --- a/packages/sdk-typescript/README.md +++ b/packages/sdk-typescript/README.md @@ -117,6 +117,8 @@ For web applications whose callback runs in a different request or process, prov The token store is intentionally storage-agnostic. `OAuthTokenStore` can be implemented over a keychain, encrypted file, database, or application-controlled browser secure-storage layer. The application owns encryption, access control, serialization, and lifecycle; the SDK only calls the adapter to load, save, clear, and serialize refresh operations. +`auth.revoke()` revokes the long-lived refresh token and the current access token before clearing the application’s local record. If either remote revocation attempt fails, the record is retained so the application can retry; consumers may explicitly clear it when their logout policy requires local-only removal. + If your application already owns token persistence and refresh, omit `tokenStore` for the authorization exchange and initialize the client with the resulting access token: ```ts diff --git a/packages/sdk-typescript/scripts/api-surface.snapshot.json b/packages/sdk-typescript/scripts/api-surface.snapshot.json index bd41333..f81510c 100644 --- a/packages/sdk-typescript/scripts/api-surface.snapshot.json +++ b/packages/sdk-typescript/scripts/api-surface.snapshot.json @@ -499,7 +499,7 @@ { "kind": "InterfaceDeclaration", "name": "OAuthEndpoints", - "declaration": "export interface OAuthEndpoints { api: string; authorization: string; token: string; revocation: string; }" + "declaration": "export interface OAuthEndpoints { api: string; authorization: string; token: string; /** OAuth token revocation endpoint. Omit to use the environment default. */ revocation?: string; }" }, { "kind": "ClassDeclaration", @@ -1531,7 +1531,7 @@ { "kind": "InterfaceDeclaration", "name": "OAuthEndpoints", - "declaration": "export interface OAuthEndpoints { api: string; authorization: string; token: string; revocation: string; }" + "declaration": "export interface OAuthEndpoints { api: string; authorization: string; token: string; /** OAuth token revocation endpoint. Omit to use the environment default. */ revocation?: string; }" }, { "kind": "ClassDeclaration", diff --git a/packages/sdk-typescript/src/auth/oauth.test.ts b/packages/sdk-typescript/src/auth/oauth.test.ts index b18a87e..2e7c6bc 100644 --- a/packages/sdk-typescript/src/auth/oauth.test.ts +++ b/packages/sdk-typescript/src/auth/oauth.test.ts @@ -510,6 +510,23 @@ describe("OAuth diagnostic regressions", () => { assert.equal(failure?.level, "error"); assert.equal(failure?.response?.status, 200); }); + + void test("OAuth revocation diagnostics expose safe lifecycle metadata", async () => { + const events: DiagnosticEvent[] = []; + const auth = new OAuthAuth(publicOptions(new MemoryTokenStore(validTokens()), { + onDiagnostic: (event: DiagnosticEvent) => events.push(event), + fetchImpl: async () => jsonResponse(200, {}), + })); + + await auth.revoke(); + + assert.equal(events.filter((event) => event.name === "revoke.request.start").length, 2); + assert.equal(events.filter((event) => event.name === "revoke" && event.level === "info").length, 2); + assert.equal(JSON.stringify(events).includes("access-1"), false); + assert.equal(JSON.stringify(events).includes("refresh-1"), false); + assert.equal(events.find((event) => event.name === "revoke")?.response?.endpoint, + "https://exchange.sandbox.gemini.com/auth/token/revoke"); + }); }); void test("PKCE S256 derivation matches the RFC 7636 example vector", async () => { @@ -1066,32 +1083,39 @@ void test("confidential refresh authenticates with its client secret", async () assert.equal("code_verifier" in body, false); }); -void test("revocation uses the current token and clears tokens only after success", async () => { +void test("revocation revokes the refresh and access tokens before clearing local tokens", async () => { const store = new MemoryTokenStore(validTokens()); - let request: Parameters | undefined; + const requests: Parameters[] = []; const auth = new OAuthAuth(publicOptions(store, { env: "sandbox", fetchImpl: async (...args: Parameters) => { - request = args; + requests.push(args); return jsonResponse(200, { result: "ok" }); }, })); await auth.revoke(); - assert.equal(request?.[0], "https://exchange.sandbox.gemini.com/auth/token/revoke"); - assert.deepEqual(JSON.parse(request?.[1].body ?? "{}"), { + assert.equal(requests.length, 2); + assert.equal(requests[0]?.[0], "https://exchange.sandbox.gemini.com/auth/token/revoke"); + assert.deepEqual(JSON.parse(requests[0]?.[1].body ?? "{}"), { + client_id: "public-client", + token: "refresh-1", + }); + assert.deepEqual(JSON.parse(requests[1]?.[1].body ?? "{}"), { client_id: "public-client", token: "access-1", }); - assert.equal(request?.[1].headers.Authorization, undefined); - assert.equal(request?.[1].redirect, "manual"); + for (const request of requests) { + assert.equal(request[1].headers.Authorization, undefined); + assert.equal(request[1].redirect, "manual"); + } assert.equal(store.record, undefined); }); void test("confidential revocation authenticates with the client secret", async () => { const store = new MemoryTokenStore(validTokens()); - let body: BoundaryRecord = {}; + const bodies: BoundaryRecord[] = []; const auth = new OAuthAuth({ env: "sandbox", client: { @@ -1102,18 +1126,17 @@ void test("confidential revocation authenticates with the client secret", async }, tokenStore: store, fetchImpl: async (_url, init) => { - body = JSON.parse(init.body ?? "{}"); + bodies.push(JSON.parse(init.body ?? "{}")); return jsonResponse(200, {}); }, }); await auth.revoke(); - assert.deepEqual(body, { - client_id: "server-client", - client_secret: "server-secret", - token: "access-1", - }); + assert.deepEqual(bodies, [ + { client_id: "server-client", client_secret: "server-secret", token: "refresh-1" }, + { client_id: "server-client", client_secret: "server-secret", token: "access-1" }, + ]); assert.equal(store.record, undefined); }); @@ -1147,7 +1170,7 @@ void test("OAuth revocation rejects redirects before reading the response body", } }); -void test("failed revocation clears the local token record", async () => { +void test("failed revocation preserves the local token record for retry", async () => { const store = new MemoryTokenStore(validTokens()); const auth = new OAuthAuth(publicOptions(store, { fetchImpl: async () => { @@ -1156,46 +1179,44 @@ void test("failed revocation clears the local token record", async () => { })); await assert.rejects(auth.revoke(), /revoke failed/); - assert.equal(store.record, undefined); + assert.deepEqual(store.record, validTokens()); }); void test("revocation uses the stored token without refreshing it", async () => { const store = new MemoryTokenStore(validTokens({ accessToken: "short-access", expiresAt: 1_700_000_000_000 })); - let refreshCalls = 0; - let revokeCalls = 0; + const bodies: BoundaryRecord[] = []; const auth = new OAuthAuth(publicOptions(store, { fetchImpl: async (_url: string, init: Parameters[1]) => { - refreshCalls++; - revokeCalls++; - assert.deepEqual(JSON.parse(init.body ?? "{}"), { - client_id: "public-client", - token: "short-access", - }); + bodies.push(JSON.parse(init.body ?? "{}")); return jsonResponse(200, {}); }, })); await auth.revoke(); - assert.equal(refreshCalls, 1); - assert.equal(revokeCalls, 1); + assert.deepEqual(bodies, [ + { client_id: "public-client", token: "refresh-1" }, + { client_id: "public-client", token: "short-access" }, + ]); assert.equal(store.record, undefined); }); void test("revocation does not re-enter the token-store lock when the clock crosses expiry", async () => { const store = new NonReentrantTokenStore(validTokens({ expiresAt: 1_700_000_000_001 })); + const bodies: BoundaryRecord[] = []; const auth = new OAuthAuth(publicOptions(store, { fetchImpl: async (_url: string, init: Parameters[1]) => { - assert.deepEqual(JSON.parse(init.body ?? "{}"), { - client_id: "public-client", - token: "access-1", - }); + bodies.push(JSON.parse(init.body ?? "{}")); return jsonResponse(200, {}); }, })); await auth.revoke(); + assert.deepEqual(bodies, [ + { client_id: "public-client", token: "refresh-1" }, + { client_id: "public-client", token: "access-1" }, + ]); assert.equal(store.record, undefined); }); @@ -1208,22 +1229,25 @@ void test("revocation cannot clear tokens saved by concurrent authorization", as announceRevoke = resolve; }); const auth = new OAuthAuth(publicOptions(store, { - fetchImpl: async () => { + fetchImpl: async (_url: string, init: Parameters[1]) => { fetchCalls++; + const body = JSON.parse(init.body ?? "{}"); + if (body.grant_type === "authorization_code") { + return jsonResponse(200, { + access_token: "replacement-access", + refresh_token: "replacement-refresh", + token_type: "bearer", + scope: "orders:read", + expires_in: 3600, + }); + } if (fetchCalls === 1) { announceRevoke(); await new Promise((resolve) => { releaseRevoke = resolve; }); - return jsonResponse(200, {}); } - return jsonResponse(200, { - access_token: "replacement-access", - refresh_token: "replacement-refresh", - token_type: "bearer", - scope: "orders:read", - expires_in: 3600, - }); + return jsonResponse(200, {}); }, })); const { transaction } = await auth.beginAuthorization(["orders:read"]); @@ -1242,6 +1266,7 @@ void test("revocation cannot clear tokens saved by concurrent authorization", as void test("revocation does not clear a replacement token written by another writer", async () => { const store = new MemoryTokenStore(validTokens()); + let fetchCalls = 0; let releaseRevoke: () => void = () => undefined; let announceRevoke: () => void = () => undefined; const revokeStarted = new Promise((resolve) => { @@ -1249,10 +1274,13 @@ void test("revocation does not clear a replacement token written by another writ }); const auth = new OAuthAuth(publicOptions(store, { fetchImpl: async () => { - announceRevoke(); - await new Promise((resolve) => { - releaseRevoke = resolve; - }); + fetchCalls++; + if (fetchCalls === 1) { + announceRevoke(); + await new Promise((resolve) => { + releaseRevoke = resolve; + }); + } return jsonResponse(200, {}); }, })); diff --git a/packages/sdk-typescript/src/auth/oauth.ts b/packages/sdk-typescript/src/auth/oauth.ts index 840d457..37a06bb 100644 --- a/packages/sdk-typescript/src/auth/oauth.ts +++ b/packages/sdk-typescript/src/auth/oauth.ts @@ -72,7 +72,8 @@ export interface OAuthEndpoints { api: string; authorization: string; token: string; - revocation: string; + /** OAuth token revocation endpoint. Omit to use the environment default. */ + revocation?: string; } export const DEFAULT_OAUTH_ENDPOINTS = { @@ -341,7 +342,7 @@ export class OAuthAuth implements AuthStrategy { readonly #client: OAuthClient; readonly #tokenStore?: OAuthTokenStore; readonly #authorizationTransactionStore?: OAuthAuthorizationTransactionStore; - readonly #endpoints: OAuthEndpoints; + readonly #endpoints: Required; readonly #fetchImpl: FetchLike; readonly #now: () => number; readonly #randomBytes: (size: number) => Uint8Array; @@ -610,22 +611,36 @@ export class OAuthAuth implements AuthStrategy { await this.#runExclusive(async () => { const current = validateStoredTokens(await tokenStore.load()); if (!current) return; - try { - await this.#revokeRequest(current.accessToken, options); - } finally { - await this.#clearStoredTokens(current.refreshToken); + const tokens = current.accessToken === current.refreshToken + ? [current.refreshToken] + : [current.refreshToken, current.accessToken]; + let firstError: unknown; + let failed = false; + for (const token of tokens) { + try { + await this.#revokeRequest(token, options); + } catch (error) { + failed = true; + firstError ??= error; + } } + if (failed) throw firstError; + await this.#clearStoredTokens(current.refreshToken); }); } - async #revokeRequest(accessToken: string, options: RequestOptions): Promise { + async #revokeRequest(token: string, options: RequestOptions): Promise { const execution = deadline(options, this.#timeoutMs); + const correlationId = crypto.randomUUID(); + const metadata = (status?: number, response?: { headers?: { get(name: string): string | null } }): ResponseMetadata => + createResponseMetadata({ endpoint: sanitizeDiagnosticUrl(this.#endpoints.revocation), method: "POST", correlationId, status, retryCount: 0, headers: response?.headers }); const requestBody: Record = { client_id: this.#client.clientId, - token: accessToken, + token, }; if (this.#client.type === "confidential") requestBody.client_secret = this.#client.clientSecret; - let response: Awaited>; + this.#emit("debug", "revoke.request.start", metadata()); + let response: Awaited> | undefined; let text: string; try { response = await withSignal(this.#fetchImpl(this.#endpoints.revocation, { @@ -649,11 +664,21 @@ export class OAuthAuth implements AuthStrategy { } text = await readBoundedResponseText(response, this.#maxResponseSizeBytes, execution.signal); } catch (cause) { - throw cause instanceof SdkError ? cause : new SdkError("OAuth token revocation failed", { cause }); + const error = cause instanceof SdkError + ? cause + : new SdkError("OAuth token revocation failed", { cause, metadata: metadata(response?.status, response) }); + this.#emit("error", "revoke.request.failure", metadata(response?.status, response), error); + throw error; } finally { execution.cleanup(); } - if (response.status >= 200 && response.status < 300) return; + if (response === undefined) { + throw new SdkError("OAuth token revocation failed"); + } + if (response.status >= 200 && response.status < 300) { + this.#emit("info", "revoke", metadata(response.status, response)); + return; + } let body: BoundaryValue; try { body = JSON.parse(text); @@ -661,12 +686,14 @@ export class OAuthAuth implements AuthStrategy { body = undefined; } const classification = classifyServerError(body, response.status); - throw new ApiError({ + const error = new ApiError({ status: response.status, reason: classification.reason, body, message: "OAuth token revocation failed", }); + this.#emit("error", "revoke.request.failure", metadata(response.status, response), error); + throw error; } async #validTokens(options: RequestOptions = {}): Promise { diff --git a/packages/sdk-typescript/src/observability/opentelemetry.test.ts b/packages/sdk-typescript/src/observability/opentelemetry.test.ts index ddc2bec..123d1dd 100644 --- a/packages/sdk-typescript/src/observability/opentelemetry.test.ts +++ b/packages/sdk-typescript/src/observability/opentelemetry.test.ts @@ -186,6 +186,42 @@ describe("terminal span lifecycle", () => { assert.equal(spans[0]?.attributes["http.response.status_code"], 200); assert.equal(spans[0]?.attributes["error.type"], "SdkError"); }); + + test("maps OAuth revocation lifecycle diagnostics to a dedicated span", () => { + const { tracer: otelTracer, spans } = tracer(); + const hooks = createOpenTelemetryHooks({ tracer: otelTracer }); + + hooks.onDiagnostic({ + level: "debug", + component: "oauth", + name: "revoke.request.start", + correlationId: "revoke-1", + response: { + endpoint: "https://exchange.gemini.com/auth/token/revoke", + method: "POST", + correlationId: "revoke-1", + retryCount: 0, + }, + }); + hooks.onDiagnostic({ + level: "info", + component: "oauth", + name: "revoke", + correlationId: "revoke-1", + response: { + endpoint: "https://exchange.gemini.com/auth/token/revoke", + method: "POST", + correlationId: "revoke-1", + retryCount: 0, + status: 200, + }, + }); + + assert.equal(spans.length, 1); + assert.equal(spans[0]?.name, "POST oauth.revoke"); + assert.equal(spans[0]?.ended, true); + assert.equal(spans[0]?.attributes["http.response.status_code"], 200); + }); }); test("keeps subscription spans open through stream diagnostics", () => { diff --git a/packages/sdk-typescript/src/observability/opentelemetry.ts b/packages/sdk-typescript/src/observability/opentelemetry.ts index 05551e4..9320c8d 100644 --- a/packages/sdk-typescript/src/observability/opentelemetry.ts +++ b/packages/sdk-typescript/src/observability/opentelemetry.ts @@ -13,6 +13,7 @@ const DEFAULT_SPAN_NAME_PREFIX = ""; const START_EVENTS = new Set([ "request.start", "token.request.start", + "revoke.request.start", "ws.request.start", "ws.subscription.start", "ws.reconnect", @@ -22,6 +23,7 @@ const SUCCESS_EVENTS = new Set([ "request.end", "token.exchange", "token.refresh", + "revoke", "ws.request.end", "ws.open", ]); @@ -128,7 +130,11 @@ function spanName(event: DiagnosticEvent, prefix: string): string { const target = event.operationContext?.operation; return qualifyName(prefix, `${event.response.method}${target ? ` ${target}` : ""}`); } - if (event.component === "oauth") return qualifyName(prefix, "POST oauth.token"); + if (event.component === "oauth") { + return qualifyName(prefix, event.name.startsWith("revoke.") || event.name === "revoke" + ? "POST oauth.revoke" + : "POST oauth.token"); + } if (event.name.startsWith("ws.request.")) { return qualifyName(prefix, `websocket.request ${metadataString(event, "method") ?? "unknown"}`); } From e5183b26dfda1bd64b3d35856f7f7a94eb689a72 Mon Sep 17 00:00:00 2001 From: Andrew Fuller Date: Fri, 28 Aug 2026 23:44:33 -0400 Subject: [PATCH 3/4] fix(sdk-typescript): require matching OAuth revocation endpoint --- packages/sdk-typescript/README.md | 2 +- .../scripts/api-surface.snapshot.json | 2 +- packages/sdk-typescript/src/auth/oauth.test.ts | 17 ++++++++++++++++- packages/sdk-typescript/src/auth/oauth.ts | 10 +++++++++- 4 files changed, 27 insertions(+), 4 deletions(-) diff --git a/packages/sdk-typescript/README.md b/packages/sdk-typescript/README.md index 9b19ca8..03cc577 100644 --- a/packages/sdk-typescript/README.md +++ b/packages/sdk-typescript/README.md @@ -136,7 +136,7 @@ const client = createClient({ env: "sandbox", auth: new BearerAuth({ accessToken `BearerAuth` does not persist or refresh credentials. Use it when the application owns the token lifecycle and only wants to hand the current access token to the SDK. Use `BrowserOAuthAuth` or server `OAuthAuth` with an `OAuthTokenStore` when the SDK should refresh and atomically save rotated refresh tokens through the application’s storage adapter. The SDK never opens a browser or starts a callback listener. -Treat the authorization callback URL as sensitive until it has been processed: it contains a one-time code. Do not log or analytics-track the full URL, and remove its query parameters from browser history after handling it. OAuth endpoint overrides must use HTTPS; loopback HTTP redirect URIs are supported for local/native flows. +Treat the authorization callback URL as sensitive until it has been processed: it contains a one-time code. Do not log or analytics-track the full URL, and remove its query parameters from browser history after handling it. OAuth endpoint overrides must use HTTPS; when overriding the authorization or token endpoint, also provide the matching revocation endpoint. Loopback HTTP redirect URIs are supported for local/native flows. --- diff --git a/packages/sdk-typescript/scripts/api-surface.snapshot.json b/packages/sdk-typescript/scripts/api-surface.snapshot.json index f81510c..18f9dac 100644 --- a/packages/sdk-typescript/scripts/api-surface.snapshot.json +++ b/packages/sdk-typescript/scripts/api-surface.snapshot.json @@ -1501,7 +1501,7 @@ { "kind": "InterfaceDeclaration", "name": "OAuthAuthOptions", - "declaration": "export interface OAuthAuthOptions { client: OAuthClient; /** Optional when the caller only needs authorization URL and code exchange. */ tokenStore?: OAuthTokenStore; /** Optional short-lived store for transactions that span requests or pages. */ authorizationTransactionStore?: OAuthAuthorizationTransactionStore; /** OAuth environment. Required to prevent accidental live authorization. */ env: Environment; /** HTTPS OAuth endpoint overrides for tests, mocks, or proxies. */ endpoints?: Partial; fetchImpl?: FetchLike; now?: () => number; /** Cryptographically secure random source. Override only for deterministic tests. */ randomBytes?: (size: number) => Uint8Array; /** Refresh this many milliseconds before expiry. Default: 60 seconds. */ refreshSkewMs?: number; /** End-to-end timeout for token exchange and refresh. Default: 30 seconds. */ timeoutMs?: number; /** Maximum OAuth response body size. Default: 1 MiB. */ maxResponseSizeBytes?: number; /** Receives safe OAuth lifecycle diagnostics. Default: silent. */ logger?: Logger; onDiagnostic?: DiagnosticListener; }" + "declaration": "export interface OAuthAuthOptions { client: OAuthClient; /** Optional when the caller only needs authorization URL and code exchange. */ tokenStore?: OAuthTokenStore; /** Optional short-lived store for transactions that span requests or pages. */ authorizationTransactionStore?: OAuthAuthorizationTransactionStore; /** OAuth environment. Required to prevent accidental live authorization. */ env: Environment; /** * HTTPS OAuth endpoint overrides for tests, mocks, or proxies. When * overriding `authorization` or `token`, also provide `revocation` so * credentials cannot be sent to a different OAuth authority. */ endpoints?: Partial; fetchImpl?: FetchLike; now?: () => number; /** Cryptographically secure random source. Override only for deterministic tests. */ randomBytes?: (size: number) => Uint8Array; /** Refresh this many milliseconds before expiry. Default: 60 seconds. */ refreshSkewMs?: number; /** End-to-end timeout for token exchange and refresh. Default: 30 seconds. */ timeoutMs?: number; /** Maximum OAuth response body size. Default: 1 MiB. */ maxResponseSizeBytes?: number; /** Receives safe OAuth lifecycle diagnostics. Default: silent. */ logger?: Logger; onDiagnostic?: DiagnosticListener; }" }, { "kind": "ClassDeclaration", diff --git a/packages/sdk-typescript/src/auth/oauth.test.ts b/packages/sdk-typescript/src/auth/oauth.test.ts index 2e7c6bc..3ea39d0 100644 --- a/packages/sdk-typescript/src/auth/oauth.test.ts +++ b/packages/sdk-typescript/src/auth/oauth.test.ts @@ -871,13 +871,27 @@ void test("OAuth rejects unsafe redirect and endpoint URLs", () => { for (const name of ["api", "authorization", "token"] as const) { assert.throws( () => new OAuthAuth(publicOptions(new MemoryTokenStore(), { - endpoints: { [name]: "http://localhost/oauth" }, + endpoints: { + [name]: "http://localhost/oauth", + revocation: "https://exchange.sandbox.gemini.com/auth/token/revoke", + }, })), (error: BoundaryValue) => error instanceof SdkError && /HTTPS/.test(error.message), ); } }); +void test("OAuth custom authorities require an explicit revocation endpoint", () => { + for (const name of ["authorization", "token"] as const) { + assert.throws( + () => new OAuthAuth(publicOptions(new MemoryTokenStore(), { + endpoints: { [name]: `https://custom-${name}.example/oauth` }, + })), + (error: BoundaryValue) => error instanceof SdkError && /endpoints\.revocation is required/.test(error.message), + ); + } +}); + void test("OAuthAuth supplies Bearer auth through HttpTransport without HMAC or nonce", async () => { let captured: Parameters[1] | undefined; const auth = new OAuthAuth(publicOptions(new MemoryTokenStore(validTokens()))); @@ -1361,6 +1375,7 @@ void test("custom endpoints override default environment URLs", async () => { endpoints: { authorization: "https://custom-auth.example.com/oauth/authorize", token: "https://custom-auth.example.com/oauth/token", + revocation: "https://custom-auth.example.com/oauth/revoke", api: "https://custom-api.example.com", }, fetchImpl: async (url: string | URL | Request) => { diff --git a/packages/sdk-typescript/src/auth/oauth.ts b/packages/sdk-typescript/src/auth/oauth.ts index 37a06bb..ff5fa30 100644 --- a/packages/sdk-typescript/src/auth/oauth.ts +++ b/packages/sdk-typescript/src/auth/oauth.ts @@ -172,7 +172,11 @@ export interface OAuthAuthOptions { authorizationTransactionStore?: OAuthAuthorizationTransactionStore; /** OAuth environment. Required to prevent accidental live authorization. */ env: Environment; - /** HTTPS OAuth endpoint overrides for tests, mocks, or proxies. */ + /** + * HTTPS OAuth endpoint overrides for tests, mocks, or proxies. When + * overriding `authorization` or `token`, also provide `revocation` so + * credentials cannot be sent to a different OAuth authority. + */ endpoints?: Partial; fetchImpl?: FetchLike; now?: () => number; @@ -413,6 +417,10 @@ export class OAuthAuth implements AuthStrategy { if (options.env !== "sandbox" && options.env !== "production") { throw new SdkError("env is required; choose \"sandbox\" or \"production\""); } + if ((options.endpoints?.authorization !== undefined || options.endpoints?.token !== undefined) && + options.endpoints?.revocation === undefined) { + throw new SdkError("endpoints.revocation is required when overriding endpoints.authorization or endpoints.token"); + } const defaults = DEFAULT_OAUTH_ENDPOINTS[options.env]; this.#endpoints = { api: validateHttpsEndpoint(options.endpoints?.api ?? defaults.api, "endpoints.api"), From d4d72e0cc5d73a58d0b78c4b0d13d012b2ac7f3a Mon Sep 17 00:00:00 2001 From: Andrew Fuller Date: Sat, 29 Aug 2026 00:43:31 -0400 Subject: [PATCH 4/4] fix(sdk-typescript): accept empty OAuth revocation responses --- .../sdk-typescript/src/auth/oauth.test.ts | 22 +++++++++++++++++++ packages/sdk-typescript/src/auth/oauth.ts | 12 +++++++--- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/packages/sdk-typescript/src/auth/oauth.test.ts b/packages/sdk-typescript/src/auth/oauth.test.ts index 3ea39d0..c23736f 100644 --- a/packages/sdk-typescript/src/auth/oauth.test.ts +++ b/packages/sdk-typescript/src/auth/oauth.test.ts @@ -1154,6 +1154,28 @@ void test("confidential revocation authenticates with the client secret", async assert.equal(store.record, undefined); }); +void test("revocation accepts successful empty response bodies", async () => { + for (const status of [200, 204]) { + const store = new MemoryTokenStore(validTokens()); + let requests = 0; + const auth = new OAuthAuth(publicOptions(store, { + fetchImpl: async () => { + requests += 1; + return { + status, + headers: { get: () => null }, + body: null, + }; + }, + })); + + await auth.revoke(); + + assert.equal(requests, 2); + assert.equal(store.record, undefined); + } +}); + void test("OAuth revocation rejects redirects before reading the response body", async () => { for (const response of [ ...[300, 301, 302, 303, 304, 305, 306, 307, 308].map((status) => ({ status })), diff --git a/packages/sdk-typescript/src/auth/oauth.ts b/packages/sdk-typescript/src/auth/oauth.ts index ff5fa30..bc0b779 100644 --- a/packages/sdk-typescript/src/auth/oauth.ts +++ b/packages/sdk-typescript/src/auth/oauth.ts @@ -649,7 +649,8 @@ export class OAuthAuth implements AuthStrategy { if (this.#client.type === "confidential") requestBody.client_secret = this.#client.clientSecret; this.#emit("debug", "revoke.request.start", metadata()); let response: Awaited> | undefined; - let text: string; + let text = ""; + let successful = false; try { response = await withSignal(this.#fetchImpl(this.#endpoints.revocation, { method: "POST", @@ -670,7 +671,12 @@ export class OAuthAuth implements AuthStrategy { cancelResponseBody(response, error); throw error; } - text = await readBoundedResponseText(response, this.#maxResponseSizeBytes, execution.signal); + if (response.status >= 200 && response.status < 300) { + successful = true; + cancelResponseBody(response, "OAuth token revocation succeeded"); + } else { + text = await readBoundedResponseText(response, this.#maxResponseSizeBytes, execution.signal); + } } catch (cause) { const error = cause instanceof SdkError ? cause @@ -683,7 +689,7 @@ export class OAuthAuth implements AuthStrategy { if (response === undefined) { throw new SdkError("OAuth token revocation failed"); } - if (response.status >= 200 && response.status < 300) { + if (successful) { this.#emit("info", "revoke", metadata(response.status, response)); return; }