Skip to content

Commit

Permalink
use new authcode flow
Browse files Browse the repository at this point in the history
  • Loading branch information
felipecsl committed Feb 20, 2025
1 parent 6be1773 commit ac12b77
Show file tree
Hide file tree
Showing 8 changed files with 710 additions and 104 deletions.
13 changes: 1 addition & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,6 @@ This is a node and browser API client for the (undocumented) Schwab WebSocket AP

- Node 16+

Create a `.env` file and set your Schwab oauth access token:

```
CLIENT_ID=your-client-id
ACCESS_TOKEN=your-access-token
REFRESH_TOKEN=your-refresh-token
TOKEN_EXPIRES_AT=your-token-expires-at
```

# Building for Node

```
Expand All @@ -27,10 +18,8 @@ yarn build
# Running the example app

```
cd src/example
yarn install
yarn link tda-wsjson-client
yarn start
node dist/example/testApp.js
```

# Supported APIs
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"lodash-es": "^4.17.21",
"obgen": "^0.5.2",
"prompts": "2.4.2",
"puppeteer": "^24.2.1",
"resolve": "1.22.4",
"ws": "^8.17.0"
},
Expand Down Expand Up @@ -81,6 +82,8 @@
"jest-websocket-mock": "^2.4.0",
"node-fetch": "^2.6.12",
"prettier": "^2.4.1",
"puppeteer-extra": "^3.3.6",
"puppeteer-extra-plugin-stealth": "^2.11.2",
"ts-node": "^10.4.0",
"ts-node-dev": "^2.0.0",
"typescript": "^5.7.3"
Expand Down
19 changes: 11 additions & 8 deletions src/client/realWsJsonClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,11 @@ export default class RealWsJsonClient implements WsJsonClient {
private buffer = new BufferedIterator<ParsedWebSocketResponse>();
private iterator = new MulticastIterator(this.buffer);
private state = ChannelState.DISCONNECTED;
private authCode?: string;
// @ts-ignore
private accessToken?: string;
// @ts-ignore
private refreshToken?: string;

constructor(
private readonly socket = new WebSocket(
Expand All @@ -157,10 +161,8 @@ export default class RealWsJsonClient implements WsJsonClient {
)
) {}

async authenticate(
accessToken: string
): Promise<RawLoginResponseBody | null> {
this.accessToken = accessToken;
async authenticate(authCode: string): Promise<RawLoginResponseBody | null> {
this.authCode = authCode;
const { state } = this;
switch (state) {
case ChannelState.DISCONNECTED:
Expand Down Expand Up @@ -196,18 +198,18 @@ export default class RealWsJsonClient implements WsJsonClient {
resolve: (value: RawLoginResponseBody) => void,
reject: (reason?: string) => void
) {
const { responseParser, buffer, accessToken } = this;
const { responseParser, buffer, authCode } = this;
const message = JSON.parse(data) as WsJsonRawMessage;
logger("⬅️\treceived %O", message);
if (isConnectionResponse(message)) {
const handler = findByTypeOrThrow(
messageHandlers,
SchwabLoginMessageHandler
);
if (!accessToken) {
throwError("access token is required, cannot authenticate");
if (!authCode) {
throwError("auth code is required, cannot authenticate");
}
this.sendMessage(handler.buildRequest(accessToken));
this.sendMessage(handler.buildRequest(authCode));
} else if (isLoginResponse(message)) {
this.handleLoginResponse(message, resolve, reject);
} else if (isSchwabLoginResponse(message)) {
Expand Down Expand Up @@ -377,6 +379,7 @@ export default class RealWsJsonClient implements WsJsonClient {
this.state = ChannelState.CONNECTED;
logger("Schwab login successful, token=%s", body.token);
this.accessToken = body.token;
this.refreshToken = loginResponse.refreshToken;
resolve(body);
} else {
this.state = ChannelState.ERROR;
Expand Down
18 changes: 14 additions & 4 deletions src/client/services/schwabLoginMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { ApiService } from "../services/apiService.js";
export type SchwabLoginResponse = {
service: "login/schwab";
token: string;
refreshToken: string;
authenticated: boolean;
};

Expand Down Expand Up @@ -38,15 +39,24 @@ export default class SchwabLoginMessageHandler
{
parseResponse(message: RawPayloadResponse): SchwabLoginResponse {
const [{ body }] = message.payload;
const { authenticated, token } = body as RawSchwabLoginResponseBody;
return { authenticated, token, service: "login/schwab" };
const {
authenticated,
token,
accessTokenInfo: { refreshToken },
} = body as RawSchwabLoginResponseBody;
return {
authenticated,
refreshToken,
token,
service: "login/schwab",
};
}

buildRequest(accessToken: string): RawPayloadRequest {
buildRequest(authCode: string): RawPayloadRequest {
return newPayload({
header: { service: "login/schwab", id: "login/schwab", ver: 0 },
params: {
authCode: accessToken,
authCode,
clientId: "TOSWeb",
redirectUri: "https://trade.thinkorswim.com/oauth",
tag: "TOSWeb",
Expand Down
68 changes: 21 additions & 47 deletions src/client/wsJsonClientAuth.ts
Original file line number Diff line number Diff line change
@@ -1,57 +1,31 @@
import { OAuth2Client, OAuth2Token } from "@badgateway/oauth2-client";
import { WsJsonClient } from "./wsJsonClient.js";
import debug from "debug";
import { WsJsonClient } from "./wsJsonClient.js";

// @ts-ignore
const logger = debug("wsJsonClientAuth");

export default class WsJsonClientAuth {
private readonly oauthClient: OAuth2Client;

constructor(
private readonly wsJsonClientFactory: () => WsJsonClient,
clientId: string,
originalFetch: typeof fetch
) {
this.oauthClient = new OAuth2Client({
server: "https://trade.thinkorswim.com/",
clientId,
clientSecret: "",
tokenEndpoint: "https://api.tdameritrade.com/v1/oauth2/token",
authorizationEndpoint: "/auth",
authenticationMethod: "client_secret_post",
// https://github.com/badgateway/oauth2-client/issues/105
fetch: (...args) => originalFetch(...args),
});
}
constructor(private readonly wsJsonClientFactory: () => WsJsonClient) {}

async authenticateWithRetry(token: OAuth2Token): Promise<AuthResult> {
async authenticateWithRetry(authCode: string): Promise<WsJsonClient> {
const client = this.wsJsonClientFactory();
try {
await client.authenticate(token.accessToken);
return { token, client };
} catch (e) {
return await this.refreshToken(token);
}
await client.authenticate(authCode);
return client;
}

async refreshToken(token: OAuth2Token): Promise<AuthResult> {
logger("attempting token refresh");
const { oauthClient } = this;
try {
const newToken = await oauthClient.refreshToken(token);
const client = this.wsJsonClientFactory();
await client.authenticate(newToken.accessToken);
// oauthClient.refreshToken() doesn't return the refresh token so we need to re-add it
const refreshedToken = { ...newToken, refreshToken: token.refreshToken };
return { token: refreshedToken, client };
} catch (e) {
console.error(`Failed to refresh token`, e);
throw e;
}
}
// async refreshToken(token: OAuth2Token): Promise<AuthResult> {
// logger("attempting token refresh");
// const { oauthClient } = this;
// try {
// const newToken = await oauthClient.refreshToken(token);
// const client = this.wsJsonClientFactory();
// await client.authenticate(newToken.accessToken);
// // oauthClient.refreshToken() doesn't return the refresh token so we need to re-add it
// const refreshedToken = { ...newToken, refreshToken: token.refreshToken };
// return { token: refreshedToken, client };
// } catch (e) {
// console.error(`Failed to refresh token`, e);
// throw e;
// }
// }
}

export type AuthResult = {
token: OAuth2Token;
client: WsJsonClient;
};
48 changes: 48 additions & 0 deletions src/example/browserOauth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import puppeteer from "puppeteer-extra";
import StealthPlugin from "puppeteer-extra-plugin-stealth";
import { PuppeteerExtra } from "puppeteer-extra";

export async function getAuthCode() {
(puppeteer as unknown as PuppeteerExtra).use(StealthPlugin());

return new Promise<string>(async (resolve) => {
const browser = await (puppeteer as unknown as PuppeteerExtra).launch({
headless: false, // or 'new' in the latest Puppeteer to get partial headless mode
args: ["--no-sandbox", "--disable-setuid-sandbox"],
});

const page = await browser.newPage();
await page.setRequestInterception(true);

console.log(
"Please log in manually. The script will watch for the final redirect URL."
);

let oauthCode = null;

page.on("request", async (request) => {
const reqUrl = request.url();
// If the request includes the OAuth code, capture it and abort
if (reqUrl.includes("trade.thinkorswim.com/oauth?code=")) {
const urlObj = new URL(reqUrl);
oauthCode = urlObj.searchParams.get("code");
console.log("OAuth Code Captured:", oauthCode);
// Abort the request so the code isn't consumed
return request.abort();
}

// Otherwise, let all other requests proceed normally
request.continue();
});

await page.goto("https://trade.thinkorswim.com/");

// Wait until we pick up the code
while (!oauthCode) {
await new Promise((resolve) => setTimeout(resolve, 1000));
}

await browser.close();
resolve(oauthCode);
});
}
35 changes: 10 additions & 25 deletions src/example/testApp.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import WsJsonClientAuth from "../client/wsJsonClientAuth.js";
import "dotenv/config";
import debug from "debug";
import "dotenv/config";
import RealWsJsonClient from "../client/realWsJsonClient.js";
import { CreateAlertRequestParams } from "../client/services/createAlertMessageHandler.js";
import { OptionQuotesRequestParams } from "../client/services/optionQuotesMessageHandler.js";
import fetch from "node-fetch";
import { WsJsonClient } from "../client/wsJsonClient.js";
import WsJsonClientAuth from "../client/wsJsonClientAuth.js";
import MarketDepthStateUpdater from "./marketDepthStateUpdater.js";
import RealWsJsonClient from "../client/realWsJsonClient.js";
import { getAuthCode } from "./browserOauth.js";

const logger = debug("testapp");

Expand Down Expand Up @@ -157,30 +157,15 @@ class TestApp {
}

async function run() {
const clientId = process.env.CLIENT_ID;
const accessToken = process.env.ACCESS_TOKEN;
const refreshToken = process.env.REFRESH_TOKEN;
const expiresAt = process.env.TOKEN_EXPIRES_AT;
if (!clientId || !accessToken || !refreshToken || !expiresAt) {
throw new Error(
"Please provide CLIENT_ID, ACCESS_TOKEN, REFRESH_TOKEN and TOKEN_EXPIRES_AT environment variables"
);
}
const token = { accessToken, refreshToken, expiresAt: +expiresAt };
const authClient = new WsJsonClientAuth(
() => new RealWsJsonClient(),
clientId,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
fetch
);
const { client } = await authClient.authenticateWithRetry(token);
const authCode = await getAuthCode();
const authClient = new WsJsonClientAuth(() => new RealWsJsonClient());
const client = await authClient.authenticateWithRetry(authCode);
const app = new TestApp(client);
await Promise.all([
// app.quotes(["ABNB", "UBER"]),
// app.accountPositions(),
app.quotes(["ABNB", "UBER"]),
app.accountPositions(),
app.optionChain("TSLA"),
// app.optionChainQuotes("AAPL"),
app.optionChainQuotes("AAPL"),
]);
}

Expand Down
Loading

0 comments on commit ac12b77

Please sign in to comment.