diff --git a/.gitignore b/.gitignore index 84cf3d2..bd0e17e 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,4 @@ logs demo/config.json demo/index.html docs/ +node_demo/.token.json diff --git a/README.md b/README.md index 7eb650f..86e6e30 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,15 @@ Simply call `handleAuthentication()` on every page that uses the library. This s const isAuthenticated = await client.auth.handleAuthentication(); ``` +#### Manual Session Management + +If you have obtained an access token (and refresh token) through other means (e.g., server-side authentication), you can manually set the session on the client. + +```javascript +// Set the access token (and optional refresh token) +client.auth.setSession('YOUR_ACCESS_TOKEN', 'YOUR_REFRESH_TOKEN'); +``` + ### 3. Fetch Data Once authenticated, you can call any endpoint using `getData`. This method handles authentication headers, automatically follows S3 links if returned by the API, and provides metadata about the response. diff --git a/node_demo/index.js b/node_demo/index.js new file mode 100644 index 0000000..aa8162d --- /dev/null +++ b/node_demo/index.js @@ -0,0 +1,129 @@ +import { IRacingClient } from '../dist/index.js'; +import * as readline from 'readline'; +import { readFile, writeFile } from 'fs/promises'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const TOKEN_FILE = path.join(__dirname, '.token.json'); + +// Parse CLI args +const args = process.argv.slice(2); +const argClientId = args[0]; +const argRedirectUri = args[1]; + +// Configuration - normally you would load this from env or config file +// For this demo, we assume the user has a registered app with iRacing +// Use the demo credentials if available or placeholders +const CONFIG = { + clientId: argClientId || process.env.IRACING_CLIENT_ID || 'ChooseAClientId', // User must supply this + redirectUri: + argRedirectUri || process.env.IRACING_REDIRECT_URI || 'http://localhost:3000/callback', // User must supply this matching their app +}; + +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, +}); + +const question = (query) => new Promise((resolve) => rl.question(query, resolve)); + +async function saveTokens(client) { + const tokens = { + accessToken: client.auth.accessToken, + refreshToken: client.auth.refreshToken, + }; + try { + await writeFile(TOKEN_FILE, JSON.stringify(tokens, null, 2)); + // console.log('Tokens saved to disk.'); + } catch (err) { + console.error('Failed to save tokens:', err); + } +} + +async function performAuth(client) { + // 1. Generate Auth URL + const authUrl = await client.auth.generateAuthUrl(); + console.log('\nPlease open the following URL in your browser to authorize:'); + console.log(authUrl); + console.log('\nAfter authorizing, you will be redirected to your Redirect URI.'); + console.log( + 'Copy the FULL URL you were redirected to (or just the code parameter) and paste it here.', + ); + + // 2. Get Code/URL from user + const callbackUrl = await question('\nPaste Callback URL or Code: '); + + // 3. Exchange code for token + console.log('Exchanging code for token...'); + await client.auth.handleCallback(callbackUrl.trim()); + + console.log('Authentication successful!'); + console.log(`Access Token: ${client.auth.accessToken.substring(0, 10)}...`); + + await saveTokens(client); +} + +async function main() { + console.log('--- iRacing Data API Node.js Demo ---'); + + if (CONFIG.clientId === 'ChooseAClientId') { + console.log('Please set IRACING_CLIENT_ID and IRACING_REDIRECT_URI environment variables,'); + console.log('or edit the CONFIG object in node_demo/index.js'); + CONFIG.clientId = await question('Enter Client ID: '); + CONFIG.redirectUri = await question('Enter Redirect URI: '); + } + + const client = new IRacingClient({ + clientId: CONFIG.clientId, + redirectUri: CONFIG.redirectUri, + }); + + // Try to load saved tokens + try { + const savedData = await readFile(TOKEN_FILE, 'utf-8'); + const tokens = JSON.parse(savedData); + if (tokens.accessToken) { + client.auth.setSession(tokens.accessToken, tokens.refreshToken); + console.log('Loaded saved tokens from .token.json'); + } + } catch (_err) { + // No saved tokens or invalid file, proceed to auth + console.log('No saved tokens found.'); + } + + try { + if (!client.auth.isLoggedIn) { + await performAuth(client); + } + + console.log('\nFetching /data/member/info...'); + + // We wrap this in a loop or retry logic effectively + let result; + try { + result = await client.getData('member/info'); + } catch (err) { + if (err.status === 401) { + console.log('Saved token is invalid or expired (and refresh failed). Re-authenticating...'); + await performAuth(client); + result = await client.getData('member/info'); + } else { + throw err; + } + } + + // Save tokens again in case they were refreshed during getData + await saveTokens(client); + + console.log('Data received!'); + console.log('Member Info:', JSON.stringify(result.data, null, 2)); + } catch (error) { + console.error('An error occurred:', error); + } finally { + rl.close(); + } +} + +main(); diff --git a/node_demo/package.json b/node_demo/package.json new file mode 100644 index 0000000..fca902d --- /dev/null +++ b/node_demo/package.json @@ -0,0 +1,10 @@ +{ + "name": "irdata_js_demo", + "version": "0.1.0", + "description": "Node.js demo for irdata_js", + "type": "module", + "main": "index.js", + "scripts": { + "start": "node index.js" + } +} diff --git a/src/auth/AuthManager.ts b/src/auth/AuthManager.ts index ef27b6b..eb01a5e 100644 --- a/src/auth/AuthManager.ts +++ b/src/auth/AuthManager.ts @@ -69,6 +69,7 @@ export class AuthManager { private config: AuthConfig; private authBaseUrl: string; private tokenEndpoint?: string; + private pkceVerifier: string | null = null; constructor( config: AuthConfig, @@ -89,6 +90,21 @@ export class AuthManager { return this.tokenStore.getAccessToken(); } + get refreshToken(): string | null { + return this.tokenStore.getRefreshToken(); + } + + /** + * Manually sets the session tokens. + * Useful for loading a saved session from external storage. + */ + setSession(accessToken: string, refreshToken?: string) { + this.tokenStore.setAccessToken(accessToken); + if (refreshToken) { + this.tokenStore.setRefreshToken(refreshToken); + } + } + /** * Returns true if an access token is present. */ @@ -158,6 +174,8 @@ export class AuthManager { // Store verifier for the callback if (typeof window !== 'undefined' && window.sessionStorage) { window.sessionStorage.setItem('irdata_pkce_verifier', verifier); + } else { + this.pkceVerifier = verifier; } const params = new URLSearchParams({ @@ -199,6 +217,11 @@ export class AuthManager { } } catch { // Not a valid URL, assume it's the code itself or handled below + // Fallback: try to extract code via regex if URL parsing failed + const match = input.match(/[?&]code=([\w-]+)/); + if (match) { + code = match[1]; + } } } @@ -208,6 +231,11 @@ export class AuthManager { window.sessionStorage.removeItem('irdata_pkce_verifier'); } + if (!verifier && this.pkceVerifier) { + verifier = this.pkceVerifier; + this.pkceVerifier = null; + } + if (!verifier) { throw new Error('No PKCE verifier found'); } diff --git a/tests/AuthManager.test.ts b/tests/AuthManager.test.ts index 085dd58..c616f8a 100644 --- a/tests/AuthManager.test.ts +++ b/tests/AuthManager.test.ts @@ -111,6 +111,20 @@ describe('AuthManager', () => { vi.unstubAllGlobals(); }); + it('should allow setting session manually and retrieving refresh token', () => { + auth.setSession('manual-access-token', 'manual-refresh-token'); + expect(auth.accessToken).toBe('manual-access-token'); + expect(auth.refreshToken).toBe('manual-refresh-token'); + expect(auth.isLoggedIn).toBe(true); + }); + + it('should allow setting session without refresh token', () => { + auth.setSession('only-access-token'); + expect(auth.accessToken).toBe('only-access-token'); + expect(auth.refreshToken).toBeNull(); + expect(auth.isLoggedIn).toBe(true); + }); + it('should use proxy authBaseUrl if provided', async () => { const proxyConfig = { authBaseUrl: 'https://proxy.com/auth' }; auth = new AuthManager(config, proxyConfig);