oauth-guard 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,180 @@
1
+ # 🛡️ oauth-guard
2
+
3
+ [![NPM Version](https://img.shields.io/npm/v/oauth-guard.svg?style=flat-square)](https://www.npmjs.com/package/oauth-guard)
4
+ [![License](https://img.shields.io/npm/l/oauth-guard.svg?style=flat-square)](./LICENSE)
5
+ [![Node Support](https://img.shields.io/node/v/oauth-guard.svg?style=flat-square)](https://nodejs.org)
6
+ [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](https://github.com/agarwalpranav0711/oauth-guard/pulls)
7
+
8
+ A zero-config, zero-dependency local proxy server that automatically manages, caches, and refreshes OAuth2 Client Credentials tokens. Built specifically to solve token expiration friction in API testing tools like Postman, Insomnia, and `curl`.
9
+
10
+ ---
11
+
12
+ ## 🛑 The Friction
13
+
14
+ When testing APIs secured by the OAuth2 **Client Credentials** grant, developers face a persistent workflow interruption.
15
+
16
+ According to the OAuth2 specification (RFC 6749, sections 4.2.2 and 4.4.3), auth servers **should not** issue a `refresh_token` for Client Credentials. Because of this, popular desktop API clients (like Postman) cannot automatically refresh tokens when they expire.
17
+
18
+ This leaves developers with three painful choices during testing:
19
+ 1. Manually trigger token requests and copy-paste new `Authorization` headers every hour.
20
+ 2. Write custom pre-request scripts in Postman that execute on *every single request*, slowing down response times and cluttering test collections.
21
+ 3. Deal with unexpected `401 Unauthorized` errors mid-flow.
22
+
23
+ ---
24
+
25
+ ## ⚡ What It Is (and What It Isn't)
26
+
27
+ `oauth-guard` runs a lightweight proxy on your local machine. You direct your API client (Postman, curl, or a local dev frontend) to `http://localhost:8787` instead of the real API base URL.
28
+
29
+ The proxy intercepts incoming calls, checks process memory for a valid token, auto-refreshes it proactively if it has expired (or is about to expire), attaches it to the `Authorization` header, and forwards your request to the target API.
30
+
31
+ * **What it is:** A simple, single-binary-equivalent Node.js developer tool configured with one JSON file. It is optimized for local loopback testing.
32
+ * **What it isn't:** It is **not** an enterprise API gateway, production sidecar, or Kubernetes service mesh component. If you need advanced cluster ingress token-management, mutual TLS (mTLS), or cluster-level caching, check out infrastructure-focused utilities like Go's `observatorium/token-refresher` or Go libraries like `udhos/oauth2`.
33
+
34
+ ---
35
+
36
+ ## 🔄 How It Works
37
+
38
+ ```mermaid
39
+ sequenceDiagram
40
+ autonumber
41
+ actor Client as API Client (Postman / curl)
42
+ participant Proxy as localhost:8787 (oauth-guard)
43
+ participant Auth as OAuth Provider (Auth0/Okta)
44
+ participant API as Target API (e.g. Gateway)
45
+
46
+ Client->>Proxy: GET /v1/users
47
+ alt Token is missing or expires in < 30s
48
+ Proxy->>Auth: POST /oauth/token (grant_type=client_credentials)
49
+ Auth-->>Proxy: Return JSON (access_token + expires_in)
50
+ end
51
+ Proxy->>API: GET /v1/users (Authorization: Bearer <token>)
52
+ API-->>Proxy: Return Data (JSON/Binary/Stream)
53
+ Proxy-->>Client: Pipe back response unchanged (200 OK)
54
+ ```
55
+
56
+ ---
57
+
58
+ ## 🛠️ Concurrency Safety (Singleflight Request Coalescing)
59
+
60
+ If your test suite launches multiple requests in parallel (or you open a dashboard making 10 concurrent API calls) while the cached token is expired or missing, standard proxies trigger 10 simultaneous token requests. This can lead to **rate-limiting blocks** or invalidating previously generated tokens.
61
+
62
+ `oauth-guard` implements **single-flight request coalescing**. When multiple concurrent requests arrive, only **one** network request is made to the token endpoint. All other concurrent requests wait in a queue and resolve safely once that single request completes.
63
+
64
+ ---
65
+
66
+ ## 🚀 Installation & Usage
67
+
68
+ ### Option 1: Run with `npx` (No Global Installation)
69
+ ```bash
70
+ npx oauth-guard start --config config.json
71
+ ```
72
+
73
+ ### Option 2: Install Globally
74
+ ```bash
75
+ npm install -g oauth-guard
76
+ ```
77
+
78
+ ---
79
+
80
+ ## 📂 Configuration Examples
81
+
82
+ Create a `config.json` file in your workspace (copy from `config.example.json` to start).
83
+
84
+ ### Example 1: Auth0 (Requires `audience` and Form Body Credentials)
85
+ ```json
86
+ {
87
+ "tokenEndpoint": "https://dev-your-tenant.us.auth0.com/oauth/token",
88
+ "clientId": "your_auth0_client_id",
89
+ "clientSecret": "your_auth0_client_secret",
90
+ "credentialStyle": "body",
91
+ "audience": "https://api.yourdomain.local",
92
+ "targetApiBaseUrl": "https://api.yourdomain.com",
93
+ "listenPort": 8787
94
+ }
95
+ ```
96
+
97
+ ### Example 2: Okta / Standard OAuth (Basic Auth Credentials + Custom Timeout)
98
+ ```json
99
+ {
100
+ "tokenEndpoint": "https://your-org.okta.com/oauth2/default/v1/token",
101
+ "clientId": "your_client_id",
102
+ "clientSecret": "your_client_secret",
103
+ "credentialStyle": "basic",
104
+ "targetApiBaseUrl": "https://api.yourdomain.com",
105
+ "listenPort": 8787,
106
+ "scope": "read:users write:users",
107
+ "targetTimeoutMs": 15000
108
+ }
109
+ ```
110
+
111
+ ---
112
+
113
+ ## ⚙️ Configuration Reference
114
+
115
+ | Parameter | Type | Required | Default | Description |
116
+ | :--- | :--- | :---: | :---: | :--- |
117
+ | `tokenEndpoint` | `string` | **Yes** | — | The full URL to request OAuth tokens (e.g., `https://auth.company.com/oauth/token`). |
118
+ | `clientId` | `string` | **Yes** | — | The client ID for application authentication. |
119
+ | `clientSecret` | `string` | **Yes** | — | The client secret for application authentication. |
120
+ | `credentialStyle`| `string` | **Yes** | — | Location of client credentials. Must be `"body"` (sent in POST request body) or `"basic"` (sent in HTTP Basic Auth headers). |
121
+ | `targetApiBaseUrl`| `string` | **Yes** | — | The base target API URL where requests will be forwarded. |
122
+ | `listenPort` | `number` | **Yes** | — | Local port the proxy binds to (e.g., `8787`). Must be an integer between 1 and 65535. |
123
+ | `audience` | `string` | No | `""` | The resource audience identifier (required by Auth0). |
124
+ | `scope` | `string` | No | `""` | Optional space-separated list of scopes to request. |
125
+ | `targetTimeoutMs`| `number` | No | `30000` | Connection timeout in milliseconds for forwarded requests to the target API. |
126
+
127
+ ---
128
+
129
+ ## 💻 CLI Commands
130
+
131
+ ### 1. `oauth-guard start`
132
+ Starts the HTTP proxy server on the configured local port.
133
+
134
+ * **Command:** `oauth-guard start --config <path>`
135
+ * **Flags:**
136
+ * `-c, --config <path>`: Path to the JSON configuration file.
137
+ * `-v, --verbose`: Enables verbose logging. Prints full outbound headers (with **redacted access tokens**), request paths, and response latency.
138
+
139
+ ### 2. `oauth-guard status`
140
+ A one-shot diagnostics tool to verify credentials and connectivity without starting the server.
141
+
142
+ * **Command:** `oauth-guard status --config <path>`
143
+ * **Output:** Displays endpoint reachability latency, credential authentication status, token lifetime remaining, and a redacted preview of the returned token.
144
+
145
+ ```text
146
+ [oauth-guard] [status] ℹ️ Checking token endpoint: https://dev-tenant.us.auth0.com/oauth/token
147
+ [oauth-guard] 🔄 Fetching new token from https://dev-tenant.us.auth0.com/oauth/token...
148
+ [oauth-guard] ✅ Token acquired, expires in 86400s
149
+
150
+ [oauth-guard] [status] ✅ Diagnostic Summary:
151
+ • Token endpoint reachability : REACHABLE (took 452ms)
152
+ • Authentication status : SUCCESSFUL
153
+ • Token lifetime / expiry : 86400s remaining (23h 59m 59s)
154
+ • Token confirmation : eyJhbG*** [redacted]
155
+ ```
156
+
157
+ ---
158
+
159
+ ## 🔒 Security Notes
160
+
161
+ > [!IMPORTANT]
162
+ > Because `oauth-guard` handles active credentials and authentication tokens, please observe the following guidelines:
163
+ >
164
+ > 1. **Local-Only Binding:** The proxy server binds and listens on `localhost` (127.0.0.1) only. Never expose the configured port to public networks or external interfaces.
165
+ > 2. **Git Safety:** Add `config.json` to your local `.gitignore` immediately. Never commit real credentials to source control. Use `config.example.json` to share schemas with team members.
166
+ > 3. **Memory Caching:** Credentials and access tokens are kept purely in-memory. They are never written to disk or serialized to local temporary folders.
167
+
168
+ ---
169
+
170
+ ## ⚠️ Limitations & Scope
171
+
172
+ * **Single Target API:** Each instance of the proxy is dedicated to one `targetApiBaseUrl`. If you need to test multiple separate target APIs, start multiple proxy instances on separate local ports.
173
+ * **Flow Support:** Supports **Client Credentials** grant type only. Authorization Code, Implicit, and Password grants are out of scope.
174
+ * **No Persistence:** Restarting the proxy clears the cached token, meaning a fresh token will be acquired on the first request after launch.
175
+
176
+ ---
177
+
178
+ ## 📄 License
179
+
180
+ MIT License. See [LICENSE](LICENSE) for details.
package/bin/cli.js ADDED
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env node
2
+
3
+ 'use strict';
4
+
5
+ const { program } = require('commander');
6
+ const { loadConfig } = require('../src/configLoader');
7
+ const { startServer } = require('../src/server');
8
+
9
+ program
10
+ .name('oauth-guard')
11
+ .description(
12
+ 'A local HTTP proxy that automatically manages OAuth2 Client Credentials tokens.'
13
+ )
14
+ .version('1.0.0');
15
+
16
+ const { fetchNewToken } = require('../src/tokenManager');
17
+
18
+ program
19
+ .command('start')
20
+ .description('Start the oauth-guard proxy server')
21
+ .requiredOption(
22
+ '-c, --config <path>',
23
+ 'Path to the JSON config file'
24
+ )
25
+ .option(
26
+ '-v, --verbose',
27
+ 'Enable verbose logging of outbound requests, headers, and timing'
28
+ )
29
+ .action((options) => {
30
+ try {
31
+ // --- Load and validate config ---
32
+ const config = loadConfig(options.config);
33
+
34
+ // --- Start the proxy server ---
35
+ const server = startServer(config, options);
36
+
37
+ // --- Graceful shutdown on Ctrl+C ---
38
+ process.on('SIGINT', () => {
39
+ console.log('\n[oauth-guard] Shutting down...');
40
+ server.close(() => {
41
+ process.exit(0);
42
+ });
43
+ // Force exit after 3s if server.close() hangs
44
+ setTimeout(() => process.exit(0), 3000);
45
+ });
46
+
47
+ // Windows-specific: handle the terminal close signal
48
+ process.on('SIGTERM', () => {
49
+ console.log('\n[oauth-guard] Shutting down...');
50
+ server.close(() => {
51
+ process.exit(0);
52
+ });
53
+ setTimeout(() => process.exit(0), 3000);
54
+ });
55
+ } catch (err) {
56
+ console.error(`\n[oauth-guard] ❌ ${err.message}\n`);
57
+ process.exit(1);
58
+ }
59
+ });
60
+
61
+ program
62
+ .command('status')
63
+ .description('Verify config and connection to the token endpoint')
64
+ .requiredOption(
65
+ '-c, --config <path>',
66
+ 'Path to the JSON config file'
67
+ )
68
+ .action(async (options) => {
69
+ try {
70
+ // --- Load and validate config ---
71
+ const config = loadConfig(options.config);
72
+
73
+ console.log(`[oauth-guard] [status] ℹ️ Checking token endpoint: ${config.tokenEndpoint}`);
74
+
75
+ // --- Attempt to fetch token ---
76
+ const startTime = Date.now();
77
+ const token = await fetchNewToken(config);
78
+ const duration = Date.now() - startTime;
79
+
80
+ console.log('');
81
+ console.log('[oauth-guard] [status] ✅ Diagnostic Summary:');
82
+ console.log(` • Token endpoint reachability : REACHABLE (took ${duration}ms)`);
83
+ console.log(' • Authentication status : SUCCESSFUL');
84
+
85
+ const secondsRemaining = Math.max(0, Math.round((token.expiresAt - Date.now()) / 1000));
86
+ const countdownStr = formatCountdown(token.expiresAt - Date.now());
87
+ console.log(` • Token lifetime / expiry : ${secondsRemaining}s remaining (${countdownStr})`);
88
+
89
+ const redacted = token.accessToken.slice(0, 6) + '*** [redacted]';
90
+ console.log(` • Token confirmation : ${redacted}`);
91
+ console.log('');
92
+ process.exit(0);
93
+ } catch (err) {
94
+ console.error(`\n[oauth-guard] [status] ❌ Diagnostic failed: ${err.message}\n`);
95
+ process.exit(1);
96
+ }
97
+ });
98
+
99
+ function formatCountdown(ms) {
100
+ if (ms <= 0) return 'Expired';
101
+ const seconds = Math.floor((ms / 1000) % 60);
102
+ const minutes = Math.floor((ms / (1000 * 60)) % 60);
103
+ const hours = Math.floor((ms / (1000 * 60 * 60)) % 24);
104
+ const days = Math.floor(ms / (1000 * 60 * 60 * 24));
105
+
106
+ const parts = [];
107
+ if (days > 0) parts.push(`${days}d`);
108
+ if (hours > 0 || days > 0) parts.push(`${hours}h`);
109
+ if (minutes > 0 || hours > 0 || days > 0) parts.push(`${minutes}m`);
110
+ parts.push(`${seconds}s`);
111
+ return parts.join(' ');
112
+ }
113
+
114
+ // Top-level error handling — clean output, no raw stack traces
115
+ try {
116
+ program.parse(process.argv);
117
+ } catch (err) {
118
+ console.error(`\n[oauth-guard] ❌ Unexpected error: ${err.message}\n`);
119
+ process.exit(1);
120
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "tokenEndpoint": "https://api.example.com/oauth/token",
3
+ "clientId": "YOUR_CLIENT_ID",
4
+ "clientSecret": "YOUR_CLIENT_SECRET",
5
+ "credentialStyle": "body",
6
+ "targetApiBaseUrl": "https://api.example.com",
7
+ "audience": "https://your-api-identifier",
8
+ "scope": "optional-scope-string",
9
+ "listenPort": 8787
10
+ }
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "oauth-guard",
3
+ "version": "1.0.0",
4
+ "description": "A zero-config local proxy that automatically fetches and refreshes OAuth2 Client Credentials tokens for API testing tools like Postman and curl.",
5
+ "bin": {
6
+ "oauth-guard": "bin/cli.js"
7
+ },
8
+ "scripts": {
9
+ "start": "node bin/cli.js start"
10
+ },
11
+ "keywords": [
12
+ "oauth2",
13
+ "client-credentials",
14
+ "proxy",
15
+ "postman",
16
+ "api-testing",
17
+ "token-refresh",
18
+ "cli"
19
+ ],
20
+ "author": "",
21
+ "license": "MIT",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/agarwalpranav0711/oauth-guard.git"
25
+ },
26
+ "bugs": {
27
+ "url": "https://github.com/agarwalpranav0711/oauth-guard/issues"
28
+ },
29
+ "homepage": "https://github.com/agarwalpranav0711/oauth-guard#readme",
30
+ "engines": {
31
+ "node": ">=18.0.0"
32
+ },
33
+ "dependencies": {
34
+ "commander": "^13.1.0"
35
+ },
36
+ "files": [
37
+ "bin/",
38
+ "src/",
39
+ "README.md",
40
+ "LICENSE",
41
+ "config.example.json"
42
+ ]
43
+ }
@@ -0,0 +1,150 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ /**
5
+ * Required fields and their expected types for a valid oauth-guard config.
6
+ */
7
+ const REQUIRED_FIELDS = {
8
+ tokenEndpoint: 'string',
9
+ clientId: 'string',
10
+ clientSecret: 'string',
11
+ credentialStyle: 'string',
12
+ targetApiBaseUrl: 'string',
13
+ listenPort: 'number',
14
+ };
15
+
16
+ /**
17
+ * Valid values for the credentialStyle field.
18
+ */
19
+ const VALID_CREDENTIAL_STYLES = ['body', 'basic'];
20
+
21
+ /**
22
+ * Loads and validates a config JSON file.
23
+ * Returns the parsed config object if valid.
24
+ * Throws a descriptive Error listing all validation problems if invalid.
25
+ *
26
+ * @param {string} configPath - Absolute or relative path to the JSON config file.
27
+ * @returns {object} The validated config object.
28
+ */
29
+ function loadConfig(configPath) {
30
+ const resolvedPath = path.resolve(configPath);
31
+
32
+ // --- File existence check ---
33
+ if (!fs.existsSync(resolvedPath)) {
34
+ throw new Error(`Config file not found: ${resolvedPath}`);
35
+ }
36
+
37
+ // --- JSON parse ---
38
+ let raw;
39
+ try {
40
+ raw = fs.readFileSync(resolvedPath, 'utf-8');
41
+ } catch (err) {
42
+ throw new Error(`Could not read config file: ${err.message}`);
43
+ }
44
+
45
+ let config;
46
+ try {
47
+ config = JSON.parse(raw);
48
+ } catch (err) {
49
+ throw new Error(`Config file is not valid JSON: ${err.message}`);
50
+ }
51
+
52
+ // --- Field validation ---
53
+ const problems = [];
54
+
55
+ for (const [field, expectedType] of Object.entries(REQUIRED_FIELDS)) {
56
+ if (!(field in config)) {
57
+ problems.push(`Missing required field: "${field}"`);
58
+ } else if (typeof config[field] !== expectedType) {
59
+ problems.push(
60
+ `Field "${field}" must be a ${expectedType}, got ${typeof config[field]}`
61
+ );
62
+ } else if (expectedType === 'string' && config[field].trim() === '') {
63
+ problems.push(`Field "${field}" must not be empty`);
64
+ }
65
+ }
66
+
67
+ // credentialStyle enum check (only if present and is a string)
68
+ if (
69
+ typeof config.credentialStyle === 'string' &&
70
+ !VALID_CREDENTIAL_STYLES.includes(config.credentialStyle)
71
+ ) {
72
+ problems.push(
73
+ `Field "credentialStyle" must be one of: ${VALID_CREDENTIAL_STYLES.join(', ')} — got "${config.credentialStyle}"`
74
+ );
75
+ }
76
+
77
+ // listenPort range check (only if present and is a number)
78
+ if (typeof config.listenPort === 'number') {
79
+ if (
80
+ !Number.isInteger(config.listenPort) ||
81
+ config.listenPort < 1 ||
82
+ config.listenPort > 65535
83
+ ) {
84
+ problems.push(
85
+ `Field "listenPort" must be an integer between 1 and 65535, got ${config.listenPort}`
86
+ );
87
+ }
88
+ }
89
+
90
+ // URL format validation
91
+ if (typeof config.tokenEndpoint === 'string' && config.tokenEndpoint.trim() !== '') {
92
+ try {
93
+ new URL(config.tokenEndpoint);
94
+ } catch (err) {
95
+ problems.push(`Field "tokenEndpoint" is not a well-formed URL: ${err.message}`);
96
+ }
97
+ }
98
+
99
+ if (typeof config.targetApiBaseUrl === 'string' && config.targetApiBaseUrl.trim() !== '') {
100
+ try {
101
+ new URL(config.targetApiBaseUrl);
102
+ } catch (err) {
103
+ problems.push(`Field "targetApiBaseUrl" is not a well-formed URL: ${err.message}`);
104
+ }
105
+ }
106
+
107
+ // targetTimeoutMs is optional; if present must be a positive integer
108
+ if ('targetTimeoutMs' in config) {
109
+ if (typeof config.targetTimeoutMs !== 'number') {
110
+ problems.push(
111
+ `Field "targetTimeoutMs" must be a number if provided, got ${typeof config.targetTimeoutMs}`
112
+ );
113
+ } else if (!Number.isInteger(config.targetTimeoutMs) || config.targetTimeoutMs <= 0) {
114
+ problems.push(
115
+ `Field "targetTimeoutMs" must be a positive integer, got ${config.targetTimeoutMs}`
116
+ );
117
+ }
118
+ } else {
119
+ // Apply default
120
+ config.targetTimeoutMs = 30000;
121
+ }
122
+
123
+ // scope is optional; if present must be a string (empty strings are silently ignored at request time)
124
+ if ('scope' in config && config.scope !== '') {
125
+ if (typeof config.scope !== 'string') {
126
+ problems.push(
127
+ `Field "scope" must be a string if provided, got ${typeof config.scope}`
128
+ );
129
+ }
130
+ }
131
+
132
+ // audience is optional; if present must be a non-empty string
133
+ if ('audience' in config && config.audience !== '') {
134
+ if (typeof config.audience !== 'string') {
135
+ problems.push(
136
+ `Field "audience" must be a string if provided, got ${typeof config.audience}`
137
+ );
138
+ }
139
+ }
140
+
141
+ if (problems.length > 0) {
142
+ throw new Error(
143
+ `Invalid config file (${resolvedPath}):\n • ${problems.join('\n • ')}`
144
+ );
145
+ }
146
+
147
+ return config;
148
+ }
149
+
150
+ module.exports = { loadConfig };
package/src/server.js ADDED
@@ -0,0 +1,174 @@
1
+ const http = require('http');
2
+ const https = require('https');
3
+ const { URL } = require('url');
4
+ const { getValidToken } = require('./tokenManager');
5
+
6
+ /**
7
+ * The currently cached token, held in module scope so it persists across
8
+ * requests for the lifetime of the proxy process.
9
+ * @type {{accessToken: string, expiresAt: number} | null}
10
+ */
11
+ let cachedToken = null;
12
+
13
+ /**
14
+ * Creates and starts the oauth-guard proxy server.
15
+ *
16
+ * @param {object} config - The validated oauth-guard config object.
17
+ * @returns {http.Server} The running HTTP server instance.
18
+ */
19
+ function startServer(config, options = {}) {
20
+ const { targetApiBaseUrl, listenPort, targetTimeoutMs } = config;
21
+ const verbose = !!options.verbose;
22
+
23
+ // Pre-parse the target base URL once
24
+ const targetUrl = new URL(targetApiBaseUrl);
25
+ const isTargetHttps = targetUrl.protocol === 'https:';
26
+ const transport = isTargetHttps ? https : http;
27
+ const targetPort = targetUrl.port || (isTargetHttps ? '443' : '80');
28
+
29
+ const server = http.createServer(async (incomingReq, incomingRes) => {
30
+ if (verbose) {
31
+ console.log(`[oauth-guard] [VERBOSE] Request received: ${incomingReq.method} ${incomingReq.url}`);
32
+ } else {
33
+ console.log(`[oauth-guard] Request received: ${incomingReq.method} ${incomingReq.url}`);
34
+ }
35
+ // Pause incoming request immediately to avoid losing events/data during the await
36
+ incomingReq.pause();
37
+
38
+ // --- Step 1: Get a valid token ---
39
+ let token;
40
+ try {
41
+ token = await getValidToken(config, cachedToken);
42
+ cachedToken = token;
43
+ } catch (err) {
44
+ console.error(`[oauth-guard] ❌ Token fetch failed: ${err.message}`);
45
+ incomingRes.writeHead(502, { 'Content-Type': 'application/json' });
46
+ incomingRes.end(
47
+ JSON.stringify({
48
+ error: 'oauth-guard: failed to acquire token',
49
+ detail: err.message,
50
+ })
51
+ );
52
+ return;
53
+ }
54
+
55
+ // --- Step 2: Build the outbound request ---
56
+ // Preserve the original path + query string exactly as received
57
+ const outboundPath =
58
+ targetUrl.pathname.replace(/\/$/, '') + incomingReq.url;
59
+
60
+ // Clone incoming headers, removing/replacing sensitive ones
61
+ const outboundHeaders = { ...incomingReq.headers };
62
+
63
+ // Remove caller's own auth — our managed token replaces it
64
+ delete outboundHeaders['authorization'];
65
+
66
+ // Replace host with the target's host
67
+ outboundHeaders['host'] = targetUrl.host;
68
+
69
+ // Inject the managed Bearer token
70
+ outboundHeaders['authorization'] = `Bearer ${token.accessToken}`;
71
+
72
+ const outboundOptions = {
73
+ hostname: targetUrl.hostname,
74
+ port: targetPort,
75
+ path: outboundPath,
76
+ method: incomingReq.method,
77
+ headers: outboundHeaders,
78
+ };
79
+
80
+ if (verbose) {
81
+ const headersToLog = {};
82
+ for (const [key, value] of Object.entries(outboundHeaders)) {
83
+ if (key.toLowerCase() === 'authorization' && typeof value === 'string') {
84
+ const parts = value.split(' ');
85
+ if (parts[0] && parts[0].toLowerCase() === 'bearer' && parts[1]) {
86
+ headersToLog[key] = `Bearer ${parts[1].slice(0, 6)}*** [redacted]`;
87
+ } else {
88
+ headersToLog[key] = '[redacted]';
89
+ }
90
+ } else {
91
+ headersToLog[key] = value;
92
+ }
93
+ }
94
+ console.log(`[oauth-guard] [VERBOSE] Sending request to target API:`);
95
+ console.log(`[oauth-guard] [VERBOSE] Method: ${incomingReq.method}`);
96
+ console.log(`[oauth-guard] [VERBOSE] Path: ${outboundPath}`);
97
+ console.log(`[oauth-guard] [VERBOSE] Headers: ${JSON.stringify(headersToLog, null, 2)}`);
98
+ }
99
+
100
+ const startTime = Date.now();
101
+
102
+ // --- Step 3: Forward the request (stream the body) ---
103
+ // NOTE: We pipe the incoming body as a raw stream to the outbound request.
104
+ // This works for JSON, form data, plain text, and binary payloads.
105
+ // For v1, streaming responses are also piped back directly.
106
+ const proxyReq = transport.request(outboundOptions, (proxyRes) => {
107
+ const duration = Date.now() - startTime;
108
+ // Calculate time remaining on the token for logging
109
+ const secondsRemaining = Math.round(
110
+ (token.expiresAt - Date.now()) / 1000
111
+ );
112
+
113
+ if (verbose) {
114
+ console.log(
115
+ `[oauth-guard] [VERBOSE] Received response from target API in ${duration}ms (status: ${proxyRes.statusCode})`
116
+ );
117
+ }
118
+
119
+ console.log(
120
+ `[oauth-guard] ${incomingReq.method} ${incomingReq.url} → ${proxyRes.statusCode} (token valid, ${secondsRemaining}s remaining)`
121
+ );
122
+
123
+ // --- Step 4: Pipe the response back unchanged ---
124
+ incomingRes.writeHead(proxyRes.statusCode, proxyRes.headers);
125
+ proxyRes.pipe(incomingRes, { end: true });
126
+ });
127
+
128
+ // Set a timeout on the outbound request
129
+ proxyReq.setTimeout(targetTimeoutMs, () => {
130
+ proxyReq.destroy(new Error(`outbound request timed out after ${targetTimeoutMs}ms`));
131
+ });
132
+
133
+ proxyReq.on('error', (err) => {
134
+ const duration = Date.now() - startTime;
135
+ if (verbose) {
136
+ console.error(
137
+ `[oauth-guard] [VERBOSE] Request failed after ${duration}ms: ${err.message}`
138
+ );
139
+ }
140
+ console.error(
141
+ `[oauth-guard] ❌ Proxy request failed: ${err.message}`
142
+ );
143
+ // Only send an error response if headers haven't been sent yet
144
+ if (!incomingRes.headersSent) {
145
+ incomingRes.writeHead(502, { 'Content-Type': 'application/json' });
146
+ incomingRes.end(
147
+ JSON.stringify({
148
+ error: 'oauth-guard: target API unreachable',
149
+ detail: err.message,
150
+ })
151
+ );
152
+ }
153
+ });
154
+
155
+ // Pipe the incoming body to the outbound request (raw stream — no parsing)
156
+ incomingReq.pipe(proxyReq, { end: true });
157
+ });
158
+
159
+ server.listen(listenPort, () => {
160
+ console.log('');
161
+ console.log(
162
+ `[oauth-guard] Starting proxy on http://localhost:${listenPort}`
163
+ );
164
+ console.log(`[oauth-guard] Forwarding to: ${targetApiBaseUrl}`);
165
+ console.log(
166
+ `[oauth-guard] Point your API client at http://localhost:${listenPort} instead of the real API.`
167
+ );
168
+ console.log('');
169
+ });
170
+
171
+ return server;
172
+ }
173
+
174
+ module.exports = { startServer };
@@ -0,0 +1,196 @@
1
+ const https = require('https');
2
+ const http = require('http');
3
+ const { URL } = require('url');
4
+
5
+ /**
6
+ * Seconds before actual expiry at which we consider the token "expired"
7
+ * and proactively refresh. This prevents edge-case failures where a token
8
+ * is technically valid when we check but expires before the target API
9
+ * processes the request.
10
+ */
11
+ const EXPIRY_BUFFER_SECONDS = 30;
12
+
13
+ /**
14
+ * In-flight token fetch promise. When a fetch is in progress, concurrent
15
+ * callers await this same promise instead of triggering redundant requests.
16
+ * @type {Promise<{accessToken: string, expiresAt: number}> | null}
17
+ */
18
+ let inFlightFetch = null;
19
+
20
+ /**
21
+ * Sends the Client Credentials token request and returns the parsed token.
22
+ *
23
+ * @param {object} config - The validated oauth-guard config object.
24
+ * @returns {Promise<{accessToken: string, expiresAt: number}>}
25
+ */
26
+ async function fetchNewToken(config) {
27
+ const { tokenEndpoint, clientId, clientSecret, credentialStyle, scope, audience } =
28
+ config;
29
+
30
+ console.log(`[oauth-guard] 🔄 Fetching new token from ${tokenEndpoint}...`);
31
+
32
+ // Build the form-urlencoded body
33
+ const bodyParams = new URLSearchParams();
34
+ bodyParams.append('grant_type', 'client_credentials');
35
+
36
+ if (credentialStyle === 'body') {
37
+ bodyParams.append('client_id', clientId);
38
+ bodyParams.append('client_secret', clientSecret);
39
+ }
40
+
41
+ if (scope) {
42
+ bodyParams.append('scope', scope);
43
+ }
44
+
45
+ if (audience) {
46
+ bodyParams.append('audience', audience);
47
+ }
48
+
49
+ const bodyString = bodyParams.toString();
50
+
51
+ // Build request options
52
+ const parsedUrl = new URL(tokenEndpoint);
53
+ const isHttps = parsedUrl.protocol === 'https:';
54
+ const transport = isHttps ? https : http;
55
+
56
+ const requestOptions = {
57
+ hostname: parsedUrl.hostname,
58
+ port: parsedUrl.port || (isHttps ? 443 : 80),
59
+ path: parsedUrl.pathname + parsedUrl.search,
60
+ method: 'POST',
61
+ headers: {
62
+ 'Content-Type': 'application/x-www-form-urlencoded',
63
+ 'Content-Length': Buffer.byteLength(bodyString),
64
+ },
65
+ };
66
+
67
+ // Add Basic Auth header if credentialStyle is "basic"
68
+ if (credentialStyle === 'basic') {
69
+ const encoded = Buffer.from(`${clientId}:${clientSecret}`).toString(
70
+ 'base64'
71
+ );
72
+ requestOptions.headers['Authorization'] = `Basic ${encoded}`;
73
+ }
74
+
75
+ // Make the request
76
+ return new Promise((resolve, reject) => {
77
+ const req = transport.request(requestOptions, (res) => {
78
+ const chunks = [];
79
+ res.on('data', (chunk) => chunks.push(chunk));
80
+ res.on('end', () => {
81
+ const rawBody = Buffer.concat(chunks).toString('utf-8');
82
+
83
+ // Non-2xx check
84
+ if (res.statusCode < 200 || res.statusCode >= 300) {
85
+ const truncated =
86
+ rawBody.length > 200 ? rawBody.slice(0, 200) + '…' : rawBody;
87
+ reject(
88
+ new Error(
89
+ `Token endpoint returned ${res.statusCode} ${res.statusMessage || ''} — check clientId/clientSecret. Response: ${truncated}`
90
+ )
91
+ );
92
+ return;
93
+ }
94
+
95
+ // JSON parse
96
+ let data;
97
+ try {
98
+ data = JSON.parse(rawBody);
99
+ } catch {
100
+ reject(
101
+ new Error(
102
+ `Token endpoint returned non-JSON response (status ${res.statusCode}) — this usually means the tokenEndpoint URL is wrong, or the server returned an HTML error page instead of JSON.`
103
+ )
104
+ );
105
+ return;
106
+ }
107
+
108
+ // Validate required fields in the token response
109
+ if (!data.access_token) {
110
+ const truncated =
111
+ rawBody.length > 200 ? rawBody.slice(0, 200) + '…' : rawBody;
112
+ reject(
113
+ new Error(
114
+ `Token endpoint response missing 'access_token' field — got: ${truncated}`
115
+ )
116
+ );
117
+ return;
118
+ }
119
+
120
+ if (data.expires_in == null || typeof data.expires_in !== 'number') {
121
+ const truncated =
122
+ rawBody.length > 200 ? rawBody.slice(0, 200) + '…' : rawBody;
123
+ reject(
124
+ new Error(
125
+ `Token endpoint response missing or invalid 'expires_in' field — got: ${truncated}`
126
+ )
127
+ );
128
+ return;
129
+ }
130
+
131
+ const expiresAt = Date.now() + data.expires_in * 1000;
132
+
133
+ console.log(
134
+ `[oauth-guard] ✅ Token acquired, expires in ${data.expires_in}s`
135
+ );
136
+
137
+ resolve({
138
+ accessToken: data.access_token,
139
+ expiresAt,
140
+ });
141
+ });
142
+ });
143
+
144
+ req.on('error', (err) => {
145
+ reject(
146
+ new Error(`Token fetch network error: ${err.message}`)
147
+ );
148
+ });
149
+
150
+ req.write(bodyString);
151
+ req.end();
152
+ });
153
+ }
154
+
155
+ /**
156
+ * Returns a valid (non-expired) token, re-fetching if necessary.
157
+ *
158
+ * Concurrency-safe: if multiple callers invoke this while a fetch is already
159
+ * in-flight, they all await the SAME promise — only one network call happens.
160
+ *
161
+ * @param {object} config - The validated oauth-guard config object.
162
+ * @param {{accessToken: string, expiresAt: number} | null} currentCachedToken
163
+ * @returns {Promise<{accessToken: string, expiresAt: number}>}
164
+ */
165
+ async function getValidToken(config, currentCachedToken) {
166
+ const now = Date.now();
167
+ const bufferMs = EXPIRY_BUFFER_SECONDS * 1000;
168
+
169
+ // If we have a cached token that's still valid (with buffer), return it
170
+ if (
171
+ currentCachedToken &&
172
+ currentCachedToken.expiresAt - now > bufferMs
173
+ ) {
174
+ return currentCachedToken;
175
+ }
176
+
177
+ // A fetch is needed. If one is already in-flight, piggyback on it.
178
+ if (inFlightFetch) {
179
+ return inFlightFetch;
180
+ }
181
+
182
+ // No in-flight fetch — start one and store the promise
183
+ inFlightFetch = fetchNewToken(config)
184
+ .then((token) => {
185
+ inFlightFetch = null;
186
+ return token;
187
+ })
188
+ .catch((err) => {
189
+ inFlightFetch = null;
190
+ throw err;
191
+ });
192
+
193
+ return inFlightFetch;
194
+ }
195
+
196
+ module.exports = { fetchNewToken, getValidToken };