Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,6 @@ jobs:
run: |
set -euo pipefail
node --check main.js
node tests/test_auth_code_extraction.js
node tests/test_proxy_config.js
bash tests/test_workflow_config_sources.sh
1 change: 1 addition & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ jobs:
run: npm start
env:
GCP_SA_KEY: ${{ secrets.GCP_SA_KEY }}
SCHWAB_PROXY_URL: ${{ secrets.SCHWAB_PROXY_URL }}
SCHWAB_USERNAME: ${{ secrets.SCHWAB_USERNAME }}
SCHWAB_PASSWORD: ${{ secrets.SCHWAB_PASSWORD }}
SCHWAB_TOTP_SECRET: ${{ secrets.SCHWAB_TOTP_SECRET }}
Expand Down
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ Go to **Settings > Secrets and variables > Actions** in your repo and add:
| `SCHWAB_API_KEY` | Your Schwab Developer App Client ID |
| `SCHWAB_APP_SECRET` | Your Schwab Developer App Client Secret |
| `GCP_SA_KEY` | JSON key for a GCP Service Account with Secret Manager permissions |
| `SCHWAB_PROXY_URL` | Optional authenticated HTTP/HTTPS proxy URL for routing Schwab browser/API traffic through your home/residential exit, e.g. `http://user:pass@proxy.example.com:3128` |

These values are better stored as **GitHub Variables** because they are configuration, not credentials:

Expand All @@ -45,6 +46,38 @@ These values are better stored as **GitHub Variables** because they are configur
3. Click **Enable workflow** (GitHub disables scheduled workflows on forks by default).
4. (Optional) Manually trigger the flow using **Run workflow** to verify the configuration.

### 3. Optional: Route Schwab traffic through your router/home exit IP

If Schwab is more reliable from your residential network than from GitHub-hosted IP ranges, this repo can route the **Schwab browser flow + token exchange request** through an authenticated proxy by setting `SCHWAB_PROXY_URL`.

This behavior is **default-off**:

- if `SCHWAB_PROXY_URL` is not configured, the workflow does **not** use a proxy
- if `SCHWAB_PROXY_URL` is configured, the workflow uses that proxy for Schwab traffic

Important boundary:

- Your **router public IP is not a proxy by itself**.
- Do **not** expose the OpenWrt/LuCI admin UI to the public internet.
- You need a separate **HTTP/HTTPS proxy service** reachable from GitHub Actions, then point `SCHWAB_PROXY_URL` at that endpoint.

Typical setup options:

1. Run a small authenticated proxy inside your home network (for example on the router or a LAN machine).
2. Expose only that proxy through your preferred tunnel/reverse-proxy setup.
3. Store the final public endpoint in `SCHWAB_PROXY_URL`, for example:

```text
SCHWAB_PROXY_URL=http://user:pass@proxy.example.com:3128
```

Notes:

- This repo currently supports **HTTP/HTTPS proxy URLs** for workflow mode.
- If `SCHWAB_PROXY_URL` is absent, the workflow runs without the home-exit proxy.
- Only the Schwab automation traffic is proxied. Package installation and GitHub/GCP housekeeping continue to use the runner's default egress.
- If you want every step to originate from home, a **self-hosted runner in your LAN** is usually simpler and more stable than tunneling a proxy into a GitHub-hosted runner.

## 📈 Architecture

1. **Trigger**: Triggered by GitHub Actions scheduler every 3 days at 13:00 UTC.
Expand Down
39 changes: 39 additions & 0 deletions lib/oauth.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
function extractAuthorizationCodeFromUrl(redirectUrl) {
if (!redirectUrl) {
return null;
}

const match = redirectUrl.match(/[?&]code=([^&]+)/);
if (!match) {
return null;
}

try {
return decodeURIComponent(match[1]);
} catch (error) {
throw new Error(`Failed to decode authorization code from redirect URL: ${error.message}`);
}
}

function summarizeAuthorizationCode(code) {
if (!code) {
return {
present: false,
};
}

return {
present: true,
length: code.length,
startsWithC0: code.startsWith('C0.'),
endsWithAt: code.endsWith('@'),
hasWhitespace: /\s/.test(code),
hasPercent: code.includes('%'),
hasPlus: code.includes('+'),
};
}

module.exports = {
extractAuthorizationCodeFromUrl,
summarizeAuthorizationCode,
};
78 changes: 78 additions & 0 deletions lib/proxy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
const { HttpsProxyAgent } = require('https-proxy-agent');
const PLAYWRIGHT_PROXY_BYPASS = 'localhost,127.0.0.1';

function resolveProxyUrl(env = process.env) {
return env.SCHWAB_PROXY_URL || null;
}

function parseProxyUrl(proxyUrl) {
if (!proxyUrl) {
return null;
}

let parsed;
try {
parsed = new URL(proxyUrl);
} catch (error) {
throw new Error(`Invalid proxy URL: ${error.message}`);
}

if (!['http:', 'https:'].includes(parsed.protocol)) {
throw new Error(`Unsupported proxy protocol "${parsed.protocol}". Only http:// and https:// are supported.`);
}

return parsed;
}

function maskProxyForLogs(proxyUrl) {
const parsed = parseProxyUrl(proxyUrl);
if (!parsed) {
return null;
}

const masked = new URL(parsed.toString());
if (masked.username) {
masked.username = '***';
}
if (masked.password) {
masked.password = '***';
}
return masked.toString();
}

function buildPlaywrightProxy(proxyUrl) {
const parsed = parseProxyUrl(proxyUrl);
if (!parsed) {
return undefined;
}

return {
server: `${parsed.protocol}//${parsed.host}`,
bypass: PLAYWRIGHT_PROXY_BYPASS,
...(parsed.username ? { username: decodeURIComponent(parsed.username) } : {}),
...(parsed.password ? { password: decodeURIComponent(parsed.password) } : {}),
};
}

function buildAxiosProxyConfig(proxyUrl) {
const parsed = parseProxyUrl(proxyUrl);
if (!parsed) {
return {};
}

const proxyAgent = new HttpsProxyAgent(parsed.toString());

return {
proxy: false,
httpAgent: proxyAgent,
httpsAgent: proxyAgent,
};
}

module.exports = {
buildAxiosProxyConfig,
buildPlaywrightProxy,
maskProxyForLogs,
parseProxyUrl,
resolveProxyUrl,
};
48 changes: 45 additions & 3 deletions main.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@ const axios = require('axios');
const { TOTP } = require('otpauth');
const { SecretManagerServiceClient } = require('@google-cloud/secret-manager');
const path = require('path');
const {
buildAxiosProxyConfig,
buildPlaywrightProxy,
maskProxyForLogs,
resolveProxyUrl,
} = require('./lib/proxy');
const {
extractAuthorizationCodeFromUrl,
summarizeAuthorizationCode,
} = require('./lib/oauth');

// --- Configuration ---
const USERNAME = process.env.SCHWAB_USERNAME;
Expand All @@ -16,6 +26,7 @@ const APP_SECRET = process.env.SCHWAB_APP_SECRET;
const PROJECT_ID = process.env.GCP_PROJECT_ID;
const SECRET_ID = process.env.GCP_SECRET_ID;
const REDIRECT_URI = process.env.SCHWAB_REDIRECT_URI;
const PROXY_URL = resolveProxyUrl(process.env);

// --- Timing constants ---
const TIMEOUTS = {
Expand Down Expand Up @@ -142,10 +153,12 @@ async function updateAndCleanupSecrets(tokenData) {
async function exchangeCodeForToken(code) {
const credentials = Buffer.from(`${APP_KEY}:${APP_SECRET}`).toString('base64');
const params = new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: REDIRECT_URI });
console.log(`Submitting token exchange with code summary: ${JSON.stringify(summarizeAuthorizationCode(code))}`);
try {
const response = await axios.post('https://api.schwabapi.com/v1/oauth/token', params.toString(), {
headers: { 'Authorization': `Basic ${credentials}`, 'Content-Type': 'application/x-www-form-urlencoded' },
timeout: 30000,
...buildAxiosProxyConfig(PROXY_URL),
});
const data = response.data;
if (!data.access_token || !data.refresh_token) {
Expand All @@ -154,7 +167,20 @@ async function exchangeCodeForToken(code) {
return data;
} catch (err) {
if (err.response) {
throw new Error(`Token exchange failed: ${err.response.status} ${JSON.stringify(err.response.data)}`);
const responseHeaders = err.response.headers || {};
const responseData = typeof err.response.data === 'string'
? err.response.data
: JSON.stringify(err.response.data);
throw new Error(`Token exchange failed: ${err.response.status} ${JSON.stringify({
body: responseData.slice(0, 300),
bodyLength: responseData.length,
headers: {
contentType: responseHeaders['content-type'] || null,
proxyAgent: responseHeaders['proxy-agent'] || null,
server: responseHeaders.server || null,
via: responseHeaders.via || null,
},
})}`);
}
throw new Error(`Token exchange network error: ${err.message}`);
}
Expand All @@ -163,6 +189,9 @@ async function exchangeCodeForToken(code) {
async function main() {
validateEnv();
console.log("Starting Chrome OAuth task on GitHub Hosted Runner...");
if (PROXY_URL) {
console.log(`Using outbound proxy for Schwab traffic: ${maskProxyForLogs(PROXY_URL)}`);
}
const authUrl = `https://api.schwabapi.com/v1/oauth/authorize?client_id=${APP_KEY}&redirect_uri=${REDIRECT_URI}`;
const userDataDir = path.resolve(__dirname, 'schwab-local-session');

Expand All @@ -175,13 +204,26 @@ async function main() {
`--window-size=${VIEWPORT.width},${VIEWPORT.height}`
],
viewport: VIEWPORT,
...(PROXY_URL ? { proxy: buildPlaywrightProxy(PROXY_URL) } : {}),
});

const page = context.pages()[0] || await context.newPage();
let interceptedCode = null;

page.on('request', r => {
if (r.url().includes('code=')) interceptedCode = new URL(r.url()).searchParams.get('code');
const requestUrl = r.url();
const extractedCode = extractAuthorizationCodeFromUrl(requestUrl);
if (!extractedCode) {
return;
}

interceptedCode = extractedCode;
const parsedUrl = new URL(requestUrl);
console.log(`Captured redirect request: ${JSON.stringify({
origin: parsedUrl.origin,
path: parsedUrl.pathname,
code: summarizeAuthorizationCode(extractedCode),
})}`);
});

try {
Expand Down Expand Up @@ -225,7 +267,7 @@ async function main() {
}
if (!interceptedCode) throw new Error("Code interception failed after polling.");

const tokenDict = await exchangeCodeForToken(interceptedCode.replace('%40', '@'));
const tokenDict = await exchangeCodeForToken(interceptedCode);
tokenDict.expires_at = Math.floor(Date.now() / 1000) + tokenDict.expires_in;
await updateAndCleanupSecrets({ creation_timestamp: Math.floor(Date.now() / 1000), token: tokenDict });
console.log("SUCCESS! Token refreshed and synced.");
Expand Down
Loading
Loading