Run a small internal HTTP service (Node is easiest) that:
-
receives { method, path/full URL, query/body }
-
signs the request using OAuth1 HMAC-SHA1
-
forwards the request to OSCAR
-
returns OSCAR’s response
n8n calls this service via a normal HTTP Request node instead of calling OSCAR directly
So the flow becomes: n8n → signer/proxy → OSCAR → signer/proxy → n8n
n8n never needs to store OAuth1 secrets in a credential, never opens an authorization page, and never needs crypto.
This is a very common pattern for legacy OAuth1 APIs and should work reliably with OSCAR if you already have the permanent token + secret.
Here’s a minimal, Docker-friendly Node OAuth1 signer/proxy you can run next to n8n. It takes a simple JSON payload from n8n, signs the request with your consumer key/secret + Postman access token/secret, forwards it to OSCAR, and returns the raw response.
1) Create these 3 files in a folder (e.g. oscar-oauth-proxy/)
docker-compose.yml
services:
oscar-oauth-proxy:
build: .
container_name: oscar-oauth-proxy
environment:
PORT: "3000"
# OSCAR base URL (no trailing slash is best)
OSCAR_BASE_URL: "https://example.com/oscar"
# From OSCAR REST Clients registration
CONSUMER_KEY: "YOUR_CONSUMER_KEY"
CONSUMER_SECRET: "YOUR_CONSUMER_SECRET"
# From Postman (permanent token + secret)
ACCESS_TOKEN: "YOUR_ACCESS_TOKEN"
ACCESS_TOKEN_SECRET: "YOUR_ACCESS_TOKEN_SECRET"
# Optional - Recommended: shared secret auth for the proxy endpoint
PROXY_SHARED_SECRET: "change-me"
ports:
- "3000:3000"
restart: unless-stopped
Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci || npm i
COPY server.js ./
EXPOSE 3000
CMD ["node", "server.js"]
package.json
{
"name": "oscar-oauth1-proxy",
"version": "1.0.0",
"type": "module",
"dependencies": {
"express": "^4.19.2",
"node-fetch": "^3.3.2",
"oauth-1.0a": "^2.2.6"
}
}
server.js
import express from "express";
import fetch from "node-fetch";
import OAuth from "oauth-1.0a";
import crypto from "crypto";
const {
PORT = 3000,
OSCAR_BASE_URL,
CONSUMER_KEY,
CONSUMER_SECRET,
ACCESS_TOKEN,
ACCESS_TOKEN_SECRET,
PROXY_SHARED_SECRET,
} = process.env;
for (const k of ["OSCAR_BASE_URL","CONSUMER_KEY","CONSUMER_SECRET","ACCESS_TOKEN","ACCESS_TOKEN_SECRET"]) {
if (!process.env[k]) throw new Error(`Missing env var: ${k}`);
}
const oauth = new OAuth({
consumer: { key: CONSUMER_KEY, secret: CONSUMER_SECRET },
signature_method: "HMAC-SHA1",
hash_function(baseString, key) {
return crypto.createHmac("sha1", key).update(baseString).digest("base64");
},
});
const token = { key: ACCESS_TOKEN, secret: ACCESS_TOKEN_SECRET };
const app = express();
app.use(express.json({ limit: "2mb" }));
/**
* POST /oscar
* Headers:
* x-proxy-secret: <PROXY_SHARED_SECRET> (if configured)
* Body:
* {
* "method": "GET"|"POST"|"PUT"|"DELETE",
* "path": "/ws/rs/...." OR full URL,
* "query": { ... }, // optional
* "body": { ... } | "raw", // optional
* "headers": { ... } // optional
* }
*/
app.post("/oscar", async (req, res) => {
try {
// Optional shared-secret protection
if (PROXY_SHARED_SECRET) {
const got = req.header("x-proxy-secret");
if (!got || got !== PROXY_SHARED_SECRET) {
return res.status(401).json({ error: "Unauthorized (proxy secret)" });
}
}
const method = String(req.body?.method || "GET").toUpperCase();
const pathOrUrl = req.body?.path;
const query = req.body?.query || {};
const body = req.body?.body;
const extraHeaders = req.body?.headers || {};
if (!pathOrUrl || typeof pathOrUrl !== "string") {
return res.status(400).json({ error: "Missing 'path' (string)" });
}
// Build URL: accept either full URL or a path appended to OSCAR_BASE_URL
let url;
if (/^https?:\/\//i.test(pathOrUrl)) {
url = new URL(pathOrUrl);
} else {
const base = OSCAR_BASE_URL.replace(/\/+$/, "");
url = new URL(base + pathOrUrl);
}
for (const [k, v] of Object.entries(query)) {
url.searchParams.set(k, String(v));
}
const requestData = { url: url.toString(), method };
const authHeader = oauth.toHeader(oauth.authorize(requestData, token));
const headers = {
Accept: "application/json, */*",
...extraHeaders,
...authHeader,
};
const opts = { method, headers };
// Add body if not GET/HEAD
if (body !== undefined && method !== "GET" && method !== "HEAD") {
// default to JSON if body is an object
if (typeof body === "object" && body !== null && !Buffer.isBuffer(body)) {
headers["Content-Type"] = headers["Content-Type"] || "application/json";
opts.body = JSON.stringify(body);
} else {
headers["Content-Type"] = headers["Content-Type"] || "text/plain";
opts.body = String(body);
}
}
const r = await fetch(url.toString(), opts);
// Pass through response status + content-type
const contentType = r.headers.get("content-type") || "text/plain";
const text = await r.text();
res.status(r.status);
res.setHeader("content-type", contentType);
res.send(text);
} catch (e) {
res.status(500).json({ error: String(e?.message || e) });
}
});
app.listen(PORT, () => {
console.log(`OSCAR OAuth1 proxy listening on :${PORT}`);
});
2) Run it
From that folder:
docker compose up -d --build
Quick smoke test
If it starts and listens, run:
curl -X POST http://localhost:3000/oscar \
-H "Content-Type: application/json" \
-H "x-proxy-secret: change-me" \
-d '{"method":"GET","path":"/ws/rs/patient/12345"}'
If that returns a 200/401 from OSCAR, your proxy wiring is correct and you can move to n8n.
3) Call it from n8n (single HTTP Request node)
HTTP Request Node
{
"method": "GET",
"path": "/ws/rs/patient/12345"
}
With query params:
{
"method": "GET",
"path": "/ws/rs/something",
"query": { "startDate": "2026-01-01", "endDate": "2026-01-31" }
}
If the proxy is reachable, you’ll get one of these outcomes:
-
200 + data → it works
-
401 from proxy → missing/wrong x-proxy-secret
-
500 from proxy → proxy env vars wrong / token invalid / upstream error
-
Connection refused / timeout → n8n cannot reach the proxy URL (networking)
Notes
-
If n8n is also in Docker, put both services on the same Docker network (Compose does this automatically if they’re in the same compose file, or you can attach networks).
-
Keep this proxy private (LAN/VPN) or protected with the x-proxy-secret header.
If anyone knows a better way or can see flaws in my answer please speak up as I’d genuinely like to learn.