diff --git a/README.md b/README.md index 62ce8a1..50b2169 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,26 @@ OPEN_API_CORS = true **Note:** If you leave `index.html` in the app code it is possible that users will be able to navigate to this page and experience a front end. This is either a bug or a feature...if you don't want users to end up in a front end then remove or rename the `index.html` file. +### Passthrough Token Mode +A registered application can make machine-to-machine calls through a running TinyNode without cloning the whole thing. Send your own registered access token in the `Authorization` header of any `/create`, `/update`, `/overwrite`, or `/delete` request: + +```shell +curl -X POST https://tiny.rerum.io/create \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"@type": "oa:Annotation", "body": "…"}' +``` + +Your token is forwarded to RERUM verbatim, so the write is attributed to your registered agent instead of the TinyNode instance's agent. TinyNode does not validate the token; RERUM rejects it with a `401` if it is bad or expired. Because you supplied the token, that `401` (or `403`) is passed back to you with its real status code so you can debug your own token. For requests without an `Authorization` header, RERUM's `401`/`403` is reported as a `502` from TinyNode whose body starts with `401:` or `403:` and includes RERUM's message, the same as any other upstream RERUM error. The only deliberate exception is `/overwrite`, which still returns `409` for version conflicts. + +This mode is on by default. An operator who wants the instance to write only with its own identity can disable it in `.env`: + +```shell +ALLOW_PASSTHROUGH_TOKENS = false +``` + +When disabled, a request that carries an `Authorization` header is rejected with `403` rather than silently attributing the write to the instance's agent. Requests without the header are unaffected and keep using the instance's identity. + # 🌟👍 Contributors 👍🌟 Trying to contribute to the public TinyThings? No way, that's awesome. Read the [Contributors Guide](CONTRIBUTING.md)! diff --git a/openapi/components/tinynode-shared-components.openapi.yaml b/openapi/components/tinynode-shared-components.openapi.yaml index 1d50249..0ee93b2 100644 --- a/openapi/components/tinynode-shared-components.openapi.yaml +++ b/openapi/components/tinynode-shared-components.openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.0.3 info: title: TinyNode Shared Components - version: 0.1.0-alpha.1 + version: 0.2.0-alpha.1 description: >- Shared schema components reused by multiple TinyNode seams. TinyNode route handlers are sourced from CenterForDigitalHumanities/TinyNode routes/ @@ -14,6 +14,15 @@ x-upstream-provider: name: rerum_server_nodejs baseUrl: https://store.rerum.io/v1 components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + description: >- + Caller-supplied RERUM access token. When present on a modification + request, TinyNode forwards it upstream verbatim and the write is + attributed to the caller's registered agent instead of the instance's + agent. schemas: HealthResponse: type: object diff --git a/passthrough.js b/passthrough.js new file mode 100644 index 0000000..68c8b1d --- /dev/null +++ b/passthrough.js @@ -0,0 +1,63 @@ +import { httpError } from "./rest.js" + +/** + * Whether this TinyNode instance accepts caller-provided Authorization + * headers (token passthrough). Defaults to true so a deployed instance + * can act as a machine-to-machine relay for registered RERUM agents. + * Set ALLOW_PASSTHROUGH_TOKENS=false (case-insensitive) to force every + * write to use this instance's own identity. + */ +export function isPassthroughAllowed() { + return String(process.env.ALLOW_PASSTHROUGH_TOKENS ?? "").trim().toLowerCase() !== "false" +} + +/** + * The Authorization header value the request should carry upstream. + * When the caller provided one and passthrough is allowed, their value + * replaces this instance's ACCESS_TOKEN. Otherwise the instance's + * default identity is used. + * + * @param {import("express").Request} req + * @returns {string} the full Authorization header value, e.g. "Bearer ..." + */ +export function resolveAuthorization(req) { + const incoming = req?.headers?.authorization + if (incoming && isPassthroughAllowed()) { + return incoming + } + return `Bearer ${process.env.ACCESS_TOKEN}` +} + +/** + * Middleware guard for token passthrough. When passthrough is disabled + * and a caller supplies an Authorization header, reject the request + * rather than silently attributing the write to this instance's agent. + * The caller's token is not validated here; RERUM is the authority. + * + * @param {import("express").Request} req + * @param {import("express").Response} res + * @param {import("express").NextFunction} next + */ +export function requirePassthroughAllowed(req, res, next) { + if (req?.headers?.authorization && !isPassthroughAllowed()) { + return next(httpError( + "Token passthrough is not allowed on this TinyNode instance. Remove the Authorization header to act as this instance's agent.", + 403 + )) + } + next() +} + +/** + * Whether the incoming request carries a caller-provided Authorization + * header that will be honored upstream. Used by checkAccessToken to + * skip the instance token refresh cycle, and by modification routes to + * decide whether an upstream 401/403 should be passed through to the + * caller instead of mapped to TinyNode's 502 contract. + * + * @param {import("express").Request} req + * @returns {boolean} + */ +export function isPassthroughRequest(req) { + return Boolean(req?.headers?.authorization && isPassthroughAllowed()) +} diff --git a/routes/create.js b/routes/create.js index 4bc03cc..d1e3a9b 100644 --- a/routes/create.js +++ b/routes/create.js @@ -1,11 +1,12 @@ import express from "express" import checkAccessToken from "../tokens.js" -import { verifyJsonContentType } from "../rest.js" +import { httpError, verifyJsonContentType } from "../rest.js" import { createRerumNetworkError, fetchRerum } from "../rerum.js" +import { isPassthroughRequest, requirePassthroughAllowed, resolveAuthorization } from "../passthrough.js" const router = express.Router() /* POST a create to the thing. */ -router.post('/', verifyJsonContentType, checkAccessToken, async (req, res, next) => { +router.post('/', verifyJsonContentType, requirePassthroughAllowed, checkAccessToken, async (req, res, next) => { try { // if an id is passed in, pop off the end to make it an _id @@ -21,7 +22,7 @@ router.post('/', verifyJsonContentType, checkAccessToken, async (req, res, next) headers: { 'user-agent': 'Tiny-Things/1.0', 'Origin': process.env.ORIGIN, - 'Authorization': `Bearer ${process.env.ACCESS_TOKEN}`, + 'Authorization': resolveAuthorization(req), 'Content-Type' : "application/json;charset=utf-8" } } @@ -29,6 +30,15 @@ router.post('/', verifyJsonContentType, checkAccessToken, async (req, res, next) const rerumResponse = await fetchRerum(createURL, createOptions) .then(async (resp) => { if (resp.ok) return resp.json() + if (isPassthroughRequest(req) && (resp.status === 401 || resp.status === 403)) { + let rerumAuthMessage + try { + rerumAuthMessage = `${resp.status}: ${createURL} - ${await resp.text()}` + } catch (e) { + rerumAuthMessage = `${resp.status}: ${createURL} - A RERUM error occurred` + } + throw httpError(rerumAuthMessage, resp.status) + } // The response from RERUM indicates a failure, likely with a specific code and textual body let rerumErrorMessage try { diff --git a/routes/delete.js b/routes/delete.js index 4bc3912..efd70aa 100644 --- a/routes/delete.js +++ b/routes/delete.js @@ -1,11 +1,12 @@ import express from "express" import checkAccessToken from "../tokens.js" -import { verifyJsonContentType } from "../rest.js" +import { httpError, verifyJsonContentType } from "../rest.js" import { fetchRerum } from "../rerum.js" +import { isPassthroughRequest, requirePassthroughAllowed, resolveAuthorization } from "../passthrough.js" const router = express.Router() /* Legacy delete pattern w/body. */ -router.delete('/', verifyJsonContentType, checkAccessToken, async (req, res, next) => { +router.delete('/', verifyJsonContentType, requirePassthroughAllowed, checkAccessToken, async (req, res, next) => { try { if (!req?.body || !(req.body['@id'] ?? req.body.id)) { const err = new Error("No record id to delete! (https://store.rerum.io/v1/API.html#delete)") @@ -20,7 +21,7 @@ router.delete('/', verifyJsonContentType, checkAccessToken, async (req, res, nex headers: { 'user-agent': 'Tiny-Things/1.0', 'Origin': process.env.ORIGIN, - 'Authorization': `Bearer ${process.env.ACCESS_TOKEN}`, + 'Authorization': resolveAuthorization(req), 'Content-Type' : "application/json; charset=utf-8" } } @@ -28,6 +29,15 @@ router.delete('/', verifyJsonContentType, checkAccessToken, async (req, res, nex await fetchRerum(deleteURL, deleteOptions) .then(async (resp) => { if (resp.ok) return + if (isPassthroughRequest(req) && (resp.status === 401 || resp.status === 403)) { + let rerumAuthMessage + try { + rerumAuthMessage = `${resp.status}: ${deleteURL} - ${await resp.text()}` + } catch (e) { + rerumAuthMessage = `${resp.status}: ${deleteURL} - A RERUM error occurred` + } + throw httpError(rerumAuthMessage, resp.status) + } let rerumErrorMessage try { rerumErrorMessage = `${resp.status ?? 500}: ${deleteURL} - ${await resp.text()}` @@ -47,7 +57,7 @@ router.delete('/', verifyJsonContentType, checkAccessToken, async (req, res, nex }) /* DELETE an object by ID via the RERUM API. */ -router.delete('/:id', checkAccessToken, async (req, res, next) => { +router.delete('/:id', requirePassthroughAllowed, checkAccessToken, async (req, res, next) => { try { const deleteURL = `${process.env.RERUM_API_ADDR}delete/${req.params.id}` @@ -56,12 +66,21 @@ router.delete('/:id', checkAccessToken, async (req, res, next) => { headers: { 'user-agent': 'Tiny-Things/1.0', 'Origin': process.env.ORIGIN, - 'Authorization': `Bearer ${process.env.ACCESS_TOKEN}`, + 'Authorization': resolveAuthorization(req), } } await fetchRerum(deleteURL, deleteOptions) .then(async (resp) => { if (resp.ok) return + if (isPassthroughRequest(req) && (resp.status === 401 || resp.status === 403)) { + let rerumAuthMessage + try { + rerumAuthMessage = `${resp.status}: ${deleteURL} - ${await resp.text()}` + } catch (e) { + rerumAuthMessage = `${resp.status}: ${deleteURL} - A RERUM error occurred` + } + throw httpError(rerumAuthMessage, resp.status) + } // The response from RERUM indicates a failure, likely with a specific code and textual body let rerumErrorMessage try { diff --git a/routes/overwrite.js b/routes/overwrite.js index 7105cbf..f02b567 100644 --- a/routes/overwrite.js +++ b/routes/overwrite.js @@ -2,10 +2,11 @@ import express from "express" import checkAccessToken from "../tokens.js" import { httpError, verifyJsonContentType } from "../rest.js" import { createRerumNetworkError, fetchRerum } from "../rerum.js" +import { isPassthroughRequest, requirePassthroughAllowed, resolveAuthorization } from "../passthrough.js" const router = express.Router() /* PUT an overwrite to the thing. */ -router.put('/', verifyJsonContentType, checkAccessToken, async (req, res, next) => { +router.put('/', verifyJsonContentType, requirePassthroughAllowed, checkAccessToken, async (req, res, next) => { try { @@ -21,7 +22,7 @@ router.put('/', verifyJsonContentType, checkAccessToken, async (req, res, next) headers: { 'user-agent': 'Tiny-Things/1.0', 'Origin': process.env.ORIGIN, - 'Authorization': `Bearer ${process.env.ACCESS_TOKEN}`, + 'Authorization': resolveAuthorization(req), 'Content-Type' : "application/json;charset=utf-8" } } @@ -50,6 +51,15 @@ router.put('/', verifyJsonContentType, checkAccessToken, async (req, res, next) err.body = conflictBody throw err } + if (isPassthroughRequest(req) && (resp.status === 401 || resp.status === 403)) { + let rerumAuthMessage + try { + rerumAuthMessage = `${resp.status}: ${overwriteURL} - ${await resp.text()}` + } catch (e) { + rerumAuthMessage = `${resp.status}: ${overwriteURL} - A RERUM error occurred` + } + throw httpError(rerumAuthMessage, resp.status) + } // The response from RERUM indicates a failure, likely with a specific code and textual body let rerumErrorMessage try { diff --git a/routes/update.js b/routes/update.js index 5e90755..f1239cb 100644 --- a/routes/update.js +++ b/routes/update.js @@ -2,10 +2,11 @@ import express from "express" import checkAccessToken from "../tokens.js" import { httpError, verifyJsonContentType } from "../rest.js" import { createRerumNetworkError, fetchRerum } from "../rerum.js" +import { isPassthroughRequest, requirePassthroughAllowed, resolveAuthorization } from "../passthrough.js" const router = express.Router() /* PUT an update to the thing. */ -router.put('/', verifyJsonContentType, checkAccessToken, async (req, res, next) => { +router.put('/', verifyJsonContentType, requirePassthroughAllowed, checkAccessToken, async (req, res, next) => { try { // check for @id; any value is valid @@ -20,7 +21,7 @@ router.put('/', verifyJsonContentType, checkAccessToken, async (req, res, next) headers: { 'user-agent': 'Tiny-Things/1.0', 'Origin': process.env.ORIGIN, - 'Authorization': `Bearer ${process.env.ACCESS_TOKEN}`, + 'Authorization': resolveAuthorization(req), 'Content-Type' : "application/json;charset=utf-8" } } @@ -28,6 +29,15 @@ router.put('/', verifyJsonContentType, checkAccessToken, async (req, res, next) const rerumResponse = await fetchRerum(updateURL, updateOptions) .then(async (resp) => { if (resp.ok) return resp.json() + if (isPassthroughRequest(req) && (resp.status === 401 || resp.status === 403)) { + let rerumAuthMessage + try { + rerumAuthMessage = `${resp.status}: ${updateURL} - ${await resp.text()}` + } catch (e) { + rerumAuthMessage = `${resp.status}: ${updateURL} - A RERUM error occurred` + } + throw httpError(rerumAuthMessage, resp.status) + } // The response from RERUM indicates a failure, likely with a specific code and textual body let rerumErrorMessage try { diff --git a/sample.env b/sample.env index 35fc575..1135d27 100644 --- a/sample.env +++ b/sample.env @@ -12,4 +12,8 @@ RERUM_ACCESS_TOKEN_URL = https://store.rerum.io/client/request-new-access-token #Make this value true if you plan to use this as a public API. OPEN_API_CORS = false -PORT = 3030 \ No newline at end of file +#Set to false to reject caller-supplied Authorization headers, forcing all +#writes to use this instance's registered agent. Default is true. +ALLOW_PASSTHROUGH_TOKENS = true + +PORT = 3030 diff --git a/test/TESTING.md b/test/TESTING.md index 6763f9f..f805a88 100644 --- a/test/TESTING.md +++ b/test/TESTING.md @@ -53,6 +53,41 @@ This catches breaking changes like: - ✅ Wrong HTTP methods - ✅ Incorrect Content-Type headers +### Token Passthrough Behavior + +Requests may carry a caller's own `Authorization` header, which replaces the instance token upstream (Passthrough Token Mode, see README). Tests validate: + +1. **Upstream contract** — The caller's header value reaches RERUM verbatim, including non-Bearer schemes +2. **Fallback** — Without a header, the instance's `Bearer ${ACCESS_TOKEN}` is used, as before +3. **Kill switch** — With `ALLOW_PASSTHROUGH_TOKENS=false`, a caller-supplied header is rejected with `403` and no upstream call is made; headerless requests still work +4. **Refresh cycle** — `checkAccessToken` skips the instance token refresh for passthrough requests, so a failed refresh cannot break a request that does not use the instance token +5. **Error contract** — For modification routes (`/create`, `/update`, `/overwrite`, `/delete`), upstream `401`/`403` responses pass through with their real status codes when the request carried a caller-supplied `Authorization` header, so the caller can debug their own token. For headerless (instance-token) requests, upstream `401`/`403` are reported as TinyNode `502` responses whose bodies start with `401:` or `403:` and include RERUM's message, preserving TinyNode's existing error contract. The only deliberate exception is `/overwrite`, which still returns `409` for version conflicts. + +Example from create.test.js: +```javascript +// Caller token reaches upstream verbatim +await request(routeTester) + .post("/create") + .set("Authorization", "Bearer caller-m2m-token") + +assert.equal(lastFetchOptions.headers["Authorization"], "Bearer caller-m2m-token") + +// Passthrough disabled means 403, not silent misattribution +process.env.ALLOW_PASSTHROUGH_TOKENS = "false" +const response = await request(routeTester) + .post("/create") + .set("Authorization", "Bearer caller-m2m-token") + +assert.equal(response.statusCode, 403) +assert.equal(lastFetchUrl, null, "upstream fetch must not happen when passthrough is rejected") +``` + +This prevents accidental changes to: + +- ✅ Verbatim header forwarding (no scheme rewriting, no local validation) +- ✅ The 403 rejection path when passthrough is disabled +- ✅ Passthrough requests pass through 401/403 with their real status codes; instance-token requests map 401/403 to 502 with RERUM's status and message in the body + ### If-Overwritten-Version Header Behavior The overwrite route includes special handling for version conflict resolution via the `If-Overwritten-Version` header. Tests validate: diff --git a/test/helpers/env.js b/test/helpers/env.js index 09f7f0d..a82f0b7 100644 --- a/test/helpers/env.js +++ b/test/helpers/env.js @@ -5,3 +5,6 @@ process.env.RERUM_ID_PATTERN ??= "https://devstore.rerum.io/v1/id/" process.env.ORIGIN ??= "http://localhost" process.env.ACCESS_TOKEN ??= "token" process.env.OPEN_API_CORS ??= "false" +// Passthrough tests assume the default. Pin it so a deploy box's .env with +// ALLOW_PASSTHROUGH_TOKENS=false cannot flip the suite (dotenv won't override this). +process.env.ALLOW_PASSTHROUGH_TOKENS = "true" diff --git a/test/routes/create.test.js b/test/routes/create.test.js index 8adb180..0fcba03 100644 --- a/test/routes/create.test.js +++ b/test/routes/create.test.js @@ -186,6 +186,147 @@ describe("Check that TinyNode create route propagates upstream and network error }) }) +describe("Check that the create route passes caller tokens through to RERUM. __mock_functions __core", () => { + it("Sends the caller's Authorization header upstream verbatim.", async () => { + const response = await request(routeTester) + .post("/create") + .set("Content-Type", "application/json") + .set("Authorization", "Bearer caller-m2m-token") + .send({ test: "item" }) + + assert.equal(response.statusCode, 201) + assert.equal(lastFetchOptions.headers["Authorization"], "Bearer caller-m2m-token") + }) + + it("Uses the instance token upstream when no Authorization header is supplied.", async () => { + const response = await request(routeTester) + .post("/create") + .set("Content-Type", "application/json") + .send({ test: "item" }) + + assert.equal(response.statusCode, 201) + assert.match(lastFetchOptions.headers["Authorization"], /^Bearer /) + assert.ok(lastFetchOptions.headers["Authorization"] !== "Bearer caller-m2m-token") + }) + + it("Rejects caller tokens with 403 when passthrough is disabled.", async () => { + process.env.ALLOW_PASSTHROUGH_TOKENS = "false" + try { + const response = await request(routeTester) + .post("/create") + .set("Content-Type", "application/json") + .set("Authorization", "Bearer caller-m2m-token") + .send({ test: "item" }) + + assert.equal(response.statusCode, 403) + assert.equal(lastFetchUrl, null, "upstream fetch must not happen when passthrough is rejected") + assert.match(response.text, /passthrough is not allowed/i) + } + finally { + delete process.env.ALLOW_PASSTHROUGH_TOKENS + } + }) + + it("Allows requests without a token when passthrough is disabled.", async () => { + process.env.ALLOW_PASSTHROUGH_TOKENS = "false" + try { + const response = await request(routeTester) + .post("/create") + .set("Content-Type", "application/json") + .send({ test: "item" }) + + assert.equal(response.statusCode, 201) + assert.match(lastFetchOptions.headers["Authorization"], /^Bearer /) + } + finally { + delete process.env.ALLOW_PASSTHROUGH_TOKENS + } + }) + + it("Passes upstream 401/403 through with real status for passthrough requests.", async () => { + global.fetch = async () => ({ + ok: false, + status: 401, + text: async () => "Unauthorized: bad or expired access token" + }) + + let response = await request(routeTester) + .post("/create") + .set("Content-Type", "application/json") + .set("Authorization", "Bearer bad-or-expired-token") + .send({ test: "item" }) + + assert.equal(response.statusCode, 401) + assert.match(response.text, /^401:/) + assert.match(response.text, /Unauthorized/) + + global.fetch = async () => ({ + ok: false, + status: 403, + text: async () => "Forbidden" + }) + + response = await request(routeTester) + .post("/create") + .set("Content-Type", "application/json") + .set("Authorization", "Bearer bad-or-expired-token") + .send({ test: "item" }) + + assert.equal(response.statusCode, 403) + assert.match(response.text, /^403:/) + assert.match(response.text, /Forbidden/) + }) + + it("Maps upstream 401/403 to 502 for instance-token requests.", async () => { + global.fetch = async () => ({ + ok: false, + status: 401, + text: async () => "Unauthorized: bad or expired access token" + }) + + let response = await request(routeTester) + .post("/create") + .set("Content-Type", "application/json") + .send({ test: "item" }) + + assert.equal(response.statusCode, 502) + assert.match(response.text, /^401:/) + assert.match(response.text, /Unauthorized/) + + global.fetch = async () => ({ + ok: false, + status: 403, + text: async () => "Forbidden" + }) + + response = await request(routeTester) + .post("/create") + .set("Content-Type", "application/json") + .send({ test: "item" }) + + assert.equal(response.statusCode, 502) + assert.match(response.text, /^403:/) + assert.match(response.text, /Forbidden/) + }) + + it("Keeps 502 for other upstream failures so passthrough errors stay distinguishable.", async () => { + global.fetch = async () => ({ + ok: false, + status: 500, + text: async () => "Upstream blew up" + }) + + const response = await request(routeTester) + .post("/create") + .set("Content-Type", "application/json") + .set("Authorization", "Bearer caller-m2m-token") + .send({ test: "item" }) + + assert.equal(response.statusCode, 502) + assert.match(response.text, /Upstream blew up/) + }) +}) + describe("Check that the properly used create endpoints function and interact with RERUM. __e2e", () => { it("'/create' route can save an object to RERUM.", async () => { const response = await request(routeTester) diff --git a/test/routes/delete.test.js b/test/routes/delete.test.js index 5803382..b7c0366 100644 --- a/test/routes/delete.test.js +++ b/test/routes/delete.test.js @@ -169,3 +169,146 @@ describe("Delete network failure and passthrough behavior. __rest __core", () = assert.match(response.text, /A RERUM error occurred/) }) }) + +describe("Check that the delete route passes caller tokens through to RERUM. __mock_functions __core", () => { + it("Sends the caller's Authorization header upstream verbatim for body delete.", async () => { + const response = await request(routeTester) + .delete("/delete") + .set("Content-Type", "application/json") + .set("Authorization", "Bearer caller-m2m-token") + .send({ "@id": rerumUri }) + + assert.equal(response.statusCode, 204) + assert.equal(lastFetchOptions.headers["Authorization"], "Bearer caller-m2m-token") + }) + + it("Sends the caller's Authorization header upstream verbatim for path delete.", async () => { + const response = await request(routeTester) + .delete("/delete/00000") + .set("Authorization", "Bearer caller-m2m-token") + + assert.equal(response.statusCode, 204) + assert.equal(lastFetchOptions.headers["Authorization"], "Bearer caller-m2m-token") + }) + + it("Rejects caller tokens with 403 when passthrough is disabled, for both delete forms.", async () => { + process.env.ALLOW_PASSTHROUGH_TOKENS = "false" + try { + let response = await request(routeTester) + .delete("/delete") + .set("Content-Type", "application/json") + .set("Authorization", "Bearer caller-m2m-token") + .send({ "@id": rerumUri }) + + assert.equal(response.statusCode, 403) + assert.equal(lastFetchUrl, null, "upstream fetch must not happen when passthrough is rejected") + assert.match(response.text, /passthrough is not allowed/i) + + response = await request(routeTester) + .delete("/delete/00000") + .set("Authorization", "Bearer caller-m2m-token") + + assert.equal(response.statusCode, 403) + assert.equal(lastFetchUrl, null, "upstream fetch must not happen when passthrough is rejected") + } + finally { + delete process.env.ALLOW_PASSTHROUGH_TOKENS + } + }) + + it("Passes upstream 401/403 through with real status for passthrough requests, for both delete forms.", async () => { + global.fetch = async () => ({ + ok: false, + status: 401, + text: async () => "Unauthorized: bad or expired access token" + }) + + let response = await request(routeTester) + .delete("/delete") + .set("Content-Type", "application/json") + .set("Authorization", "Bearer bad-or-expired-token") + .send({ "@id": rerumUri }) + + assert.equal(response.statusCode, 401) + assert.match(response.text, /^401:/) + assert.match(response.text, /Unauthorized/) + + response = await request(routeTester) + .delete("/delete/00000") + .set("Authorization", "Bearer bad-or-expired-token") + + assert.equal(response.statusCode, 401) + assert.match(response.text, /^401:/) + assert.match(response.text, /Unauthorized/) + + global.fetch = async () => ({ + ok: false, + status: 403, + text: async () => "Forbidden" + }) + + response = await request(routeTester) + .delete("/delete") + .set("Content-Type", "application/json") + .set("Authorization", "Bearer bad-or-expired-token") + .send({ "@id": rerumUri }) + + assert.equal(response.statusCode, 403) + assert.match(response.text, /^403:/) + assert.match(response.text, /Forbidden/) + + response = await request(routeTester) + .delete("/delete/00000") + .set("Authorization", "Bearer bad-or-expired-token") + + assert.equal(response.statusCode, 403) + assert.match(response.text, /^403:/) + assert.match(response.text, /Forbidden/) + }) + + it("Maps upstream 401/403 to 502 for instance-token requests, for both delete forms.", async () => { + global.fetch = async () => ({ + ok: false, + status: 401, + text: async () => "Unauthorized: bad or expired access token" + }) + + let response = await request(routeTester) + .delete("/delete") + .set("Content-Type", "application/json") + .send({ "@id": rerumUri }) + + assert.equal(response.statusCode, 502) + assert.match(response.text, /^401:/) + assert.match(response.text, /Unauthorized/) + + response = await request(routeTester) + .delete("/delete/00000") + + assert.equal(response.statusCode, 502) + assert.match(response.text, /^401:/) + assert.match(response.text, /Unauthorized/) + + global.fetch = async () => ({ + ok: false, + status: 403, + text: async () => "Forbidden" + }) + + response = await request(routeTester) + .delete("/delete") + .set("Content-Type", "application/json") + .send({ "@id": rerumUri }) + + assert.equal(response.statusCode, 502) + assert.match(response.text, /^403:/) + assert.match(response.text, /Forbidden/) + + response = await request(routeTester) + .delete("/delete/00000") + + assert.equal(response.statusCode, 502) + assert.match(response.text, /^403:/) + assert.match(response.text, /Forbidden/) + }) +}) diff --git a/test/routes/overwrite.test.js b/test/routes/overwrite.test.js index e64d92d..ede442c 100644 --- a/test/routes/overwrite.test.js +++ b/test/routes/overwrite.test.js @@ -121,6 +121,103 @@ describe("Overwrite conflict and header contract behavior. __rest __core", () = assert.equal(response.statusCode, 409) assert.equal(response.body.message, "Version conflict") }) + + it("Passes upstream 401/403 through with real status for passthrough requests.", async () => { + global.fetch = async () => ({ + ok: false, + status: 401, + text: async () => "Unauthorized: bad or expired access token" + }) + + let response = await request(routeTester) + .put("/overwrite") + .set("Content-Type", "application/json") + .set("Authorization", "Bearer bad-or-expired-token") + .send({ "@id": rerumTinyTestObjId, testing: "item" }) + + assert.equal(response.statusCode, 401) + assert.match(response.text, /^401:/) + assert.match(response.text, /Unauthorized/) + + global.fetch = async () => ({ + ok: false, + status: 403, + text: async () => "Forbidden" + }) + + response = await request(routeTester) + .put("/overwrite") + .set("Content-Type", "application/json") + .set("Authorization", "Bearer bad-or-expired-token") + .send({ "@id": rerumTinyTestObjId, testing: "item" }) + + assert.equal(response.statusCode, 403) + assert.match(response.text, /^403:/) + assert.match(response.text, /Forbidden/) + }) + + it("Maps upstream 401/403 to 502 for instance-token requests.", async () => { + global.fetch = async () => ({ + ok: false, + status: 401, + text: async () => "Unauthorized: bad or expired access token" + }) + + let response = await request(routeTester) + .put("/overwrite") + .set("Content-Type", "application/json") + .send({ "@id": rerumTinyTestObjId, testing: "item" }) + + assert.equal(response.statusCode, 502) + assert.match(response.text, /^401:/) + assert.match(response.text, /Unauthorized/) + + global.fetch = async () => ({ + ok: false, + status: 403, + text: async () => "Forbidden" + }) + + response = await request(routeTester) + .put("/overwrite") + .set("Content-Type", "application/json") + .send({ "@id": rerumTinyTestObjId, testing: "item" }) + + assert.equal(response.statusCode, 502) + assert.match(response.text, /^403:/) + assert.match(response.text, /Forbidden/) + }) +}) + +describe("Check that the overwrite route passes caller tokens through to RERUM. __mock_functions __core", () => { + it("Sends the caller's Authorization header upstream verbatim.", async () => { + const response = await request(routeTester) + .put("/overwrite") + .set("Content-Type", "application/json") + .set("Authorization", "Bearer caller-m2m-token") + .send({ "@id": rerumTinyTestObjId, testing: "item" }) + + assert.equal(response.statusCode, 200) + assert.equal(lastFetchOptions.headers["Authorization"], "Bearer caller-m2m-token") + }) + + it("Rejects caller tokens with 403 when passthrough is disabled.", async () => { + process.env.ALLOW_PASSTHROUGH_TOKENS = "false" + try { + const response = await request(routeTester) + .put("/overwrite") + .set("Content-Type", "application/json") + .set("Authorization", "Bearer caller-m2m-token") + .send({ "@id": rerumTinyTestObjId, testing: "item" }) + + assert.equal(response.statusCode, 403) + assert.equal(lastFetchUrl, null, "upstream fetch must not happen when passthrough is rejected") + assert.match(response.text, /passthrough is not allowed/i) + } + finally { + delete process.env.ALLOW_PASSTHROUGH_TOKENS + } + }) }) describe("Overwrite If-Overwritten-Version header behavior. __mock_functions", () => { diff --git a/test/routes/passthrough.test.js b/test/routes/passthrough.test.js new file mode 100644 index 0000000..7c4cad0 --- /dev/null +++ b/test/routes/passthrough.test.js @@ -0,0 +1,202 @@ +import "../helpers/env.js" +import assert from "node:assert/strict" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { afterEach, describe, it } from "node:test" +import { + isPassthroughAllowed, + isPassthroughRequest, + requirePassthroughAllowed, + resolveAuthorization, +} from "../../passthrough.js" +import checkAccessToken from "../../tokens.js" + +const originalFetch = global.fetch +const originalAllowPassthrough = process.env.ALLOW_PASSTHROUGH_TOKENS +const originalAccessToken = process.env.ACCESS_TOKEN +const originalAccessTokenUrl = process.env.RERUM_ACCESS_TOKEN_URL +const originalRefreshToken = process.env.REFRESH_TOKEN +const originalCwd = process.cwd() +const tempDirs = [] + +async function inTempCwd(run, envContent) { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "tinynode-passthrough-test-")) + tempDirs.push(tempDir) + if (envContent !== undefined) { + await fs.writeFile(path.join(tempDir, ".env"), envContent) + } + process.chdir(tempDir) + await run(tempDir) +} + +afterEach(async () => { + global.fetch = originalFetch + process.env.ACCESS_TOKEN = originalAccessToken + process.env.RERUM_ACCESS_TOKEN_URL = originalAccessTokenUrl + process.env.REFRESH_TOKEN = originalRefreshToken + if (originalAllowPassthrough === undefined) { + delete process.env.ALLOW_PASSTHROUGH_TOKENS + } + else { + process.env.ALLOW_PASSTHROUGH_TOKENS = originalAllowPassthrough + } + process.chdir(originalCwd) + while (tempDirs.length > 0) { + const tempDir = tempDirs.pop() + await fs.rm(tempDir, { recursive: true, force: true }) + } +}) + +function jwtWithExp(expSeconds) { + const payload = Buffer.from(JSON.stringify({ exp: expSeconds })).toString("base64") + return `header.${payload}.signature` +} + +describe("Passthrough helper behavior. __core __mock_functions", () => { + it("Allows passthrough by default with no ALLOW_PASSTHROUGH_TOKENS value.", () => { + delete process.env.ALLOW_PASSTHROUGH_TOKENS + assert.equal(isPassthroughAllowed(), true) + }) + + it("Disables passthrough when ALLOW_PASSTHROUGH_TOKENS is 'false' case-insensitively.", () => { + process.env.ALLOW_PASSTHROUGH_TOKENS = "false" + assert.equal(isPassthroughAllowed(), false) + process.env.ALLOW_PASSTHROUGH_TOKENS = "FALSE" + assert.equal(isPassthroughAllowed(), false) + process.env.ALLOW_PASSTHROUGH_TOKENS = " False " + assert.equal(isPassthroughAllowed(), false) + process.env.ALLOW_PASSTHROUGH_TOKENS = "0" + assert.equal(isPassthroughAllowed(), true) + process.env.ALLOW_PASSTHROUGH_TOKENS = "true" + assert.equal(isPassthroughAllowed(), true) + }) + + it("resolveAuthorization uses the caller's header verbatim when passthrough is allowed.", () => { + const req = { headers: { authorization: "Bearer caller-token" } } + process.env.ACCESS_TOKEN = "instance-token" + assert.equal(resolveAuthorization(req), "Bearer caller-token") + }) + + it("resolveAuthorization preserves non-Bearer schemes verbatim.", () => { + const req = { headers: { authorization: "Basic dXNlcjpwYXNz" } } + assert.equal(resolveAuthorization(req), "Basic dXNlcjpwYXNz") + }) + + it("resolveAuthorization falls back to the instance token when no header is present.", () => { + process.env.ACCESS_TOKEN = "instance-token" + assert.equal(resolveAuthorization({ headers: {} }), "Bearer instance-token") + assert.equal(resolveAuthorization({}), "Bearer instance-token") + }) + + it("resolveAuthorization ignores the caller's token when passthrough is disabled.", () => { + process.env.ALLOW_PASSTHROUGH_TOKENS = "false" + process.env.ACCESS_TOKEN = "instance-token" + const req = { headers: { authorization: "Bearer caller-token" } } + assert.equal(resolveAuthorization(req), "Bearer instance-token") + }) +}) + +describe("Passthrough guard middleware behavior. __core __mock_functions", () => { + it("Calls next() when no Authorization header is present, passthrough disabled.", () => { + process.env.ALLOW_PASSTHROUGH_TOKENS = "false" + let called = 0 + requirePassthroughAllowed({ headers: {} }, {}, err => { + assert.equal(err, undefined) + called += 1 + }) + assert.equal(called, 1) + }) + + it("Calls next() when an Authorization header is present and passthrough is allowed.", () => { + let called = 0 + requirePassthroughAllowed({ headers: { authorization: "Bearer caller-token" } }, {}, err => { + assert.equal(err, undefined) + called += 1 + }) + assert.equal(called, 1) + }) + + it("Rejects with 403 when a token is supplied and passthrough is disabled.", () => { + process.env.ALLOW_PASSTHROUGH_TOKENS = "false" + let receivedError + requirePassthroughAllowed({ headers: { authorization: "Bearer caller-token" } }, {}, err => { + receivedError = err + }) + assert.ok(receivedError, "guard must hand an error to next()") + assert.equal(receivedError.status, 403) + assert.match(receivedError.message, /passthrough is not allowed/i) + }) +}) + +describe("Passthrough interaction with checkAccessToken. __core __mock_functions", () => { + it("Skips the instance token refresh cycle for passthrough requests.", async () => { + // Expired instance token would normally trigger a refresh fetch. + process.env.ACCESS_TOKEN = jwtWithExp(Math.floor(Date.now() / 1000) - 60) + global.fetch = async () => { + throw new Error("refresh fetch must not happen for passthrough requests") + } + + let nextError + await checkAccessToken({ headers: { authorization: "Bearer caller-token" } }, {}, err => { + nextError = err + }) + + assert.equal(nextError, undefined) + }) + + it("Still refreshes the instance token when the request has no Authorization header.", async () => { + await inTempCwd(async () => { + process.env.ACCESS_TOKEN = jwtWithExp(Math.floor(Date.now() / 1000) - 60) + process.env.RERUM_ACCESS_TOKEN_URL = "https://auth.example/token" + process.env.REFRESH_TOKEN = "refresh-token" + let refreshCalled = 0 + global.fetch = async (url, options) => { + refreshCalled += 1 + assert.equal(url, "https://auth.example/token") + assert.equal(options.method, "POST") + return { + json: async () => ({ access_token: "new-access-token" }) + } + } + + let nextError + await checkAccessToken({ headers: {} }, {}, err => { + nextError = err + }) + + assert.equal(nextError, undefined) + assert.equal(refreshCalled, 1) + assert.equal(process.env.ACCESS_TOKEN, "new-access-token") + }, `ACCESS_TOKEN=old-token +RERUM_ACCESS_TOKEN_URL=https://auth.example/token +REFRESH_TOKEN=refresh-token`) + }) + + it("Does NOT skip the refresh cycle when passthrough is disabled, even with a header.", async () => { + await inTempCwd(async () => { + process.env.ALLOW_PASSTHROUGH_TOKENS = "false" + process.env.ACCESS_TOKEN = jwtWithExp(Math.floor(Date.now() / 1000) - 60) + process.env.RERUM_ACCESS_TOKEN_URL = "https://auth.example/token" + process.env.REFRESH_TOKEN = "refresh-token" + let refreshCalled = 0 + global.fetch = async () => { + refreshCalled += 1 + return { + json: async () => ({ access_token: "refreshed-anyway" }) + } + } + + let nextError + await checkAccessToken({ headers: { authorization: "Bearer caller-token" } }, {}, err => { + nextError = err + }) + + assert.equal(nextError, undefined) + assert.equal(refreshCalled, 1) + }, `ACCESS_TOKEN=old-token +ALLOW_PASSTHROUGH_TOKENS=false +RERUM_ACCESS_TOKEN_URL=https://auth.example/token +REFRESH_TOKEN=refresh-token`) + }) +}) diff --git a/test/routes/update.test.js b/test/routes/update.test.js index a6b5949..09d52e2 100644 --- a/test/routes/update.test.js +++ b/test/routes/update.test.js @@ -167,6 +167,119 @@ describe("Update network failure behavior. __rest __core", () => { }) }) +describe("Check that the update route passes caller tokens through to RERUM. __mock_functions __core", () => { + it("Sends the caller's Authorization header upstream verbatim.", async () => { + const response = await request(routeTester) + .put("/update") + .set("Content-Type", "application/json") + .set("Authorization", "Bearer caller-m2m-token") + .send({ "@id": rerumUriOrig, testing: "item" }) + + assert.equal(response.statusCode, 200) + assert.equal(lastFetchOptions.headers["Authorization"], "Bearer caller-m2m-token") + }) + + it("Rejects caller tokens with 403 when passthrough is disabled.", async () => { + process.env.ALLOW_PASSTHROUGH_TOKENS = "false" + try { + const response = await request(routeTester) + .put("/update") + .set("Content-Type", "application/json") + .set("Authorization", "Bearer caller-m2m-token") + .send({ "@id": rerumUriOrig, testing: "item" }) + + assert.equal(response.statusCode, 403) + assert.equal(lastFetchUrl, null, "upstream fetch must not happen when passthrough is rejected") + assert.match(response.text, /passthrough is not allowed/i) + } + finally { + delete process.env.ALLOW_PASSTHROUGH_TOKENS + } + }) + + it("Passes upstream 401/403 through with real status for passthrough requests.", async () => { + global.fetch = async () => ({ + ok: false, + status: 401, + text: async () => "Unauthorized: bad or expired access token" + }) + + let response = await request(routeTester) + .put("/update") + .set("Content-Type", "application/json") + .set("Authorization", "Bearer bad-or-expired-token") + .send({ "@id": rerumUriOrig, testing: "item" }) + + assert.equal(response.statusCode, 401) + assert.match(response.text, /^401:/) + assert.match(response.text, /Unauthorized/) + + global.fetch = async () => ({ + ok: false, + status: 403, + text: async () => "Forbidden" + }) + + response = await request(routeTester) + .put("/update") + .set("Content-Type", "application/json") + .set("Authorization", "Bearer bad-or-expired-token") + .send({ "@id": rerumUriOrig, testing: "item" }) + + assert.equal(response.statusCode, 403) + assert.match(response.text, /^403:/) + assert.match(response.text, /Forbidden/) + }) + + it("Maps upstream 401/403 to 502 for instance-token requests.", async () => { + global.fetch = async () => ({ + ok: false, + status: 401, + text: async () => "Unauthorized: bad or expired access token" + }) + + let response = await request(routeTester) + .put("/update") + .set("Content-Type", "application/json") + .send({ "@id": rerumUriOrig, testing: "item" }) + + assert.equal(response.statusCode, 502) + assert.match(response.text, /^401:/) + assert.match(response.text, /Unauthorized/) + + global.fetch = async () => ({ + ok: false, + status: 403, + text: async () => "Forbidden" + }) + + response = await request(routeTester) + .put("/update") + .set("Content-Type", "application/json") + .send({ "@id": rerumUriOrig, testing: "item" }) + + assert.equal(response.statusCode, 502) + assert.match(response.text, /^403:/) + assert.match(response.text, /Forbidden/) + }) + it("Keeps 502 for other upstream failures so passthrough errors stay distinguishable.", async () => { + global.fetch = async () => ({ + ok: false, + status: 500, + text: async () => "Upstream blew up" + }) + + const response = await request(routeTester) + .put("/update") + .set("Content-Type", "application/json") + .set("Authorization", "Bearer caller-m2m-token") + .send({ "@id": rerumUriOrig, testing: "item" }) + + assert.equal(response.statusCode, 502) + assert.match(response.text, /Upstream blew up/) + }) +}) + describe("Check that the properly used update endpoints function and interact with RERUM. __e2e", () => { it("'/update' route can update an object in RERUM.", async () => { const response = await request(routeTester) diff --git a/tokens.js b/tokens.js index 26cf467..2091bef 100644 --- a/tokens.js +++ b/tokens.js @@ -2,6 +2,7 @@ import dotenv from "dotenv" dotenv.config() import fs from "node:fs/promises" import { parse, stringify } from "envfile" +import { isPassthroughRequest } from "./passthrough.js" const sourcePath = '.env' @@ -68,6 +69,13 @@ async function generateNewAccessToken() { */ async function checkAccessToken(req, res, next) { try { + // Passthrough requests carry the caller's own token upstream, so the + // instance token is not in play. Skip its refresh cycle entirely; + // a failed refresh must not fail a request that does not use it. + if (isPassthroughRequest(req)) { + next() + return + } // If the instance of TinyNode is not registered and does not have a token then there is nothing to check. // Move on through the middleware. RERUM will tell you what you did wrong. if(!process?.env?.ACCESS_TOKEN) {