tradestation-client 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example ADDED
@@ -0,0 +1,2 @@
1
+ CLIENT_ID=
2
+ CLIENT_SECRET=
package/.prettierrc ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "singleQuote": true,
3
+ "semi": false
4
+ }
@@ -0,0 +1,235 @@
1
+ import { randomBytes } from 'node:crypto'
2
+ import { readFile, writeFile } from 'node:fs/promises'
3
+ import { join } from 'node:path'
4
+ import type { Middleware } from 'openapi-fetch'
5
+ import fastify from 'fastify'
6
+ import open from 'open'
7
+
8
+ import { redirectPort, redirectUri } from './config.ts'
9
+
10
+ type AuthResponse = {
11
+ access_token: string
12
+ refresh_token: string
13
+ id_token: string
14
+ scope: string
15
+ expires_in: number
16
+ token_type: string
17
+ }
18
+
19
+ type AuthData = AuthResponse & {
20
+ timestamp: number // Unix timestamp in milliseconds when token was obtained
21
+ }
22
+
23
+ const authFilePath = join(process.cwd(), 'tradestation-auth.json')
24
+
25
+ function generateRandomState(): string {
26
+ return randomBytes(32).toString('base64url')
27
+ }
28
+
29
+ async function tryLoadAuth(): Promise<AuthData | null> {
30
+ try {
31
+ const data = await readFile(authFilePath, 'utf-8')
32
+ return JSON.parse(data)
33
+ } catch {
34
+ return null
35
+ }
36
+ }
37
+
38
+ async function saveAuth(authResponse: AuthResponse): Promise<AuthData> {
39
+ const authData: AuthData = {
40
+ ...authResponse,
41
+ timestamp: Date.now(),
42
+ }
43
+
44
+ await writeFile(authFilePath, JSON.stringify(authData, null, 2), 'utf-8')
45
+ return authData
46
+ }
47
+
48
+ function isTokenValid(token: AuthData): boolean {
49
+ const now = Date.now()
50
+ const expiresAt = token.timestamp + token.expires_in * 1000
51
+ // Consider token expired 30 seconds before actual expiration to account for clock skew
52
+ return now < expiresAt - 30000
53
+ }
54
+
55
+ async function requestAccessToken(
56
+ code: string,
57
+ clientId: string,
58
+ clientSecret: string
59
+ ): Promise<AuthData> {
60
+ const authRes = await fetch('https://signin.tradestation.com/oauth/token', {
61
+ method: 'POST',
62
+ headers: {
63
+ 'Content-Type': 'application/x-www-form-urlencoded',
64
+ },
65
+ body: new URLSearchParams({
66
+ grant_type: 'authorization_code',
67
+ code,
68
+ redirect_uri: redirectUri,
69
+ client_id: clientId,
70
+ client_secret: clientSecret,
71
+ }),
72
+ })
73
+
74
+ const authResponse = (await authRes.json()) as AuthResponse
75
+
76
+ if (!authRes.ok) {
77
+ throw new Error(`Failed to obtain access token: ${authResponse}`)
78
+ }
79
+ return saveAuth(authResponse)
80
+ }
81
+
82
+ async function refreshAccessToken(
83
+ refreshToken: string,
84
+ clientId: string,
85
+ clientSecret: string
86
+ ): Promise<AuthData> {
87
+ const authRes = await fetch('https://signin.tradestation.com/oauth/token', {
88
+ method: 'POST',
89
+ headers: {
90
+ 'Content-Type': 'application/x-www-form-urlencoded',
91
+ },
92
+ body: new URLSearchParams({
93
+ grant_type: 'refresh_token',
94
+ client_id: clientId,
95
+ client_secret: clientSecret,
96
+ refresh_token: refreshToken,
97
+ }),
98
+ })
99
+
100
+ if (!authRes.ok) {
101
+ throw new Error(`Failed to refresh token: ${await authRes.text()}`)
102
+ }
103
+
104
+ const authResponse = (await authRes.json()) as AuthResponse
105
+
106
+ // Preserve the original refresh token if a new one wasn't provided
107
+ // (TradeStation doesn't rotate refresh tokens by default)
108
+ if (!authResponse.refresh_token) {
109
+ authResponse.refresh_token = refreshToken
110
+ }
111
+
112
+ return saveAuth(authResponse)
113
+ }
114
+
115
+ async function authenticateWithOAuth2(
116
+ clientId: string,
117
+ clientSecret: string
118
+ ): Promise<AuthData> {
119
+ const app = fastify()
120
+ const state = generateRandomState()
121
+
122
+ const authPromise = new Promise<AuthData>((resolve, reject) => {
123
+ app.get('/', async (request, reply) => {
124
+ const { code, state: callbackState } = request.query as {
125
+ code?: string
126
+ state?: string
127
+ }
128
+
129
+ if (!code) {
130
+ reply.code(400)
131
+ return 'No code provided'
132
+ }
133
+
134
+ if (callbackState !== state) {
135
+ reply.code(401)
136
+ reject(new Error('State parameter mismatch - possible CSRF attack'))
137
+ return 'State validation failed'
138
+ }
139
+
140
+ resolve(await requestAccessToken(code, clientId, clientSecret))
141
+
142
+ setTimeout(() => {
143
+ app.close()
144
+ }, 100)
145
+
146
+ return 'Authorization successful! You can close this tab.'
147
+ })
148
+ })
149
+
150
+ await app.listen({ port: redirectPort })
151
+
152
+ console.log('Opening browser for authentication...')
153
+
154
+ await openAuthUrl(state, clientId)
155
+
156
+ return authPromise
157
+ }
158
+
159
+ async function openAuthUrl(state: string, clientId: string) {
160
+ const authUrl = new URL('https://signin.tradestation.com/authorize')
161
+ authUrl.searchParams.append('response_type', 'code')
162
+ authUrl.searchParams.append(
163
+ 'scope',
164
+ 'openid offline_access profile MarketData ReadAccount Trade Matrix'
165
+ )
166
+ authUrl.searchParams.append('redirect_uri', redirectUri)
167
+ authUrl.searchParams.append('client_id', clientId)
168
+ authUrl.searchParams.append('state', state)
169
+ authUrl.searchParams.append('audience', 'https://api.tradestation.com')
170
+
171
+ await open(authUrl.toString())
172
+ }
173
+
174
+ export async function authenticate(
175
+ clientId: string,
176
+ clientSecret: string
177
+ ): Promise<AuthData> {
178
+ const auth = await tryLoadAuth()
179
+
180
+ if (!auth) {
181
+ return authenticateWithOAuth2(clientId, clientSecret)
182
+ }
183
+
184
+ if (isTokenValid(auth)) {
185
+ return auth
186
+ }
187
+
188
+ try {
189
+ return await refreshAccessToken(auth.refresh_token, clientId, clientSecret)
190
+ } catch (error) {
191
+ console.error('Token refresh failed, starting OAuth flow:', error)
192
+ }
193
+
194
+ return authenticateWithOAuth2(clientId, clientSecret)
195
+ }
196
+
197
+ export function createAuthMiddleware(
198
+ clientId: string,
199
+ clientSecret: string
200
+ ): Middleware {
201
+ return {
202
+ async onRequest({ request }) {
203
+ const auth = await authenticate(clientId, clientSecret)
204
+
205
+ request.headers.set('Authorization', `Bearer ${auth.access_token}`)
206
+ return request
207
+ },
208
+ async onResponse({ response, request }) {
209
+ if (response.status === 401) {
210
+ console.warn(
211
+ `Received 401 Unauthorized for request to ${request.url}. Access token may be invalid or expired.`
212
+ )
213
+
214
+ let auth = await tryLoadAuth()
215
+ if (!auth) {
216
+ throw new Error('No authentication data available to refresh token.')
217
+ }
218
+
219
+ auth = await refreshAccessToken(
220
+ auth.refresh_token,
221
+ clientId,
222
+ clientSecret
223
+ )
224
+
225
+ response = await fetch(new Request(request), {
226
+ headers: {
227
+ Authorization: `Bearer ${auth.access_token}`,
228
+ },
229
+ })
230
+
231
+ return response
232
+ }
233
+ },
234
+ }
235
+ }
package/cli.ts ADDED
@@ -0,0 +1,13 @@
1
+ #! /usr/bin/env node
2
+ import { program } from 'commander'
3
+ import { authenticate } from './authMiddleware.ts'
4
+
5
+ program
6
+ .requiredOption('--clientId <clientId>')
7
+ .requiredOption('--clientSecret <clientSecret>')
8
+
9
+ program.parse()
10
+
11
+ const { clientId, clientSecret } = program.opts()
12
+
13
+ console.log(await authenticate(clientId, clientSecret))