diff --git a/passthrough.js b/passthrough.js new file mode 100644 index 0000000..cbc475c --- /dev/null +++ b/passthrough.js @@ -0,0 +1,16 @@ +export function resolveAuthorization(req) { + const incoming = req.headers?.authorization; + if (incoming) { + return incoming; + } + return `******`; +} + +export function requirePassthroughAllowed(req, res, next) { + const incoming = req.headers?.authorization; + const allow = process.env.ALLOW_PASSTHROUGH_TOKENS; + if (incoming && allow && allow.toLowerCase() === 'false') { + return res.status(403).send('Token passthrough is not allowed on this TinyNode instance'); + } + next(); +} diff --git a/routes/create.js b/routes/create.js index 4bc03cc..8424064 100644 --- a/routes/create.js +++ b/routes/create.js @@ -1,11 +1,12 @@ import express from "express" import checkAccessToken from "../tokens.js" +import { resolveAuthorization, requirePassthroughAllowed } from "../passthrough.js" import { verifyJsonContentType } from "../rest.js" import { createRerumNetworkError, fetchRerum } from "../rerum.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 @@ -25,7 +26,8 @@ router.post('/', verifyJsonContentType, checkAccessToken, async (req, res, next) 'Content-Type' : "application/json;charset=utf-8" } } - const createURL = `${process.env.RERUM_API_ADDR}create` + createOptions.headers['Authorization'] = resolveAuthorization(req); +const createURL = `${process.env.RERUM_API_ADDR}create` const rerumResponse = await fetchRerum(createURL, createOptions) .then(async (resp) => { if (resp.ok) return resp.json() diff --git a/routes/delete.js b/routes/delete.js index 4bc3912..baca538 100644 --- a/routes/delete.js +++ b/routes/delete.js @@ -1,11 +1,12 @@ import express from "express" import checkAccessToken from "../tokens.js" +import { resolveAuthorization, requirePassthroughAllowed } from "../passthrough.js" import { verifyJsonContentType } from "../rest.js" import { fetchRerum } from "../rerum.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)") @@ -47,7 +48,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}` diff --git a/routes/update.js b/routes/update.js index 5e90755..b71815e 100644 --- a/routes/update.js +++ b/routes/update.js @@ -1,11 +1,12 @@ import express from "express" import checkAccessToken from "../tokens.js" +import { resolveAuthorization, requirePassthroughAllowed } from "../passthrough.js" import { httpError, verifyJsonContentType } from "../rest.js" import { createRerumNetworkError, fetchRerum } from "../rerum.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 @@ -24,7 +25,8 @@ router.put('/', verifyJsonContentType, checkAccessToken, async (req, res, next) 'Content-Type' : "application/json;charset=utf-8" } } - const updateURL = `${process.env.RERUM_API_ADDR}update` + updateOptions.headers['Authorization'] = resolveAuthorization(req); +const updateURL = `${process.env.RERUM_API_ADDR}update` const rerumResponse = await fetchRerum(updateURL, updateOptions) .then(async (resp) => { if (resp.ok) return resp.json() diff --git a/test/routes/passthrough.test.js b/test/routes/passthrough.test.js new file mode 100644 index 0000000..a0f7340 --- /dev/null +++ b/test/routes/passthrough.test.js @@ -0,0 +1,47 @@ +import "../helpers/env.js"; +import assert from "node:assert/strict"; +import { afterEach, beforeEach, describe, it } from "node:test"; +import express from "express"; +import request from "supertest"; +import createRoute from "../../routes/create.js"; +import { messenger } from "../../error-messenger.js"; + +const routeTester = express(); +routeTester.use(express.json({ type: ['application/json', 'application/ld+json'] })); +routeTester.use(express.urlencoded({ extended: false })); +routeTester.use('/create', createRoute); +routeTester.use('/app/create', createRoute); +routeTester.use(messenger); + +let originalFetch = global.fetch; +let lastFetchOptions = null; + +beforeEach(() => { + lastFetchOptions = null; + global.fetch = async (url, opts) => { + lastFetchOptions = opts; + return { + json: async () => ({ "@id": "dummy", test: "item" }), + ok: true, + text: async () => "" + }; + }; +}); + +afterEach(() => { + global.fetch = originalFetch; +}); + +describe('Passthrough Authorization header preservation', () => { + it('forwards the exact Authorization header to upstream', async () => { + const token = 'Bearer ABC123'; + const resp = await request(routeTester) + .post('/create') + .set('Authorization', token) + .set('Content-Type', 'application/json') + .send({ test: 'item' }); + assert.equal(resp.statusCode, 201); + assert.ok(lastFetchOptions, 'fetch should have been called'); + assert.equal(lastFetchOptions.headers['Authorization'], token); + }); +}); diff --git a/tokens.js b/tokens.js index 26cf467..92578f8 100644 --- a/tokens.js +++ b/tokens.js @@ -88,4 +88,29 @@ async function checkAccessToken(req, res, next) { } -export default checkAccessToken +export default function checkAccessToken(req, res, next) { + // If request carries an Authorization header, treat it as passthrough and skip instance token checks/refresh + if (req.headers?.authorization) { + return next(); + } + // Existing logic for instance token handling + (async () => { + try { + if(!process?.env?.ACCESS_TOKEN) { + next(); + return; + } + if (isTokenExpired(process.env.ACCESS_TOKEN)) { + console.log("TinyNode detected an expired access token. Updating the token now."); + await generateNewAccessToken(); + } + next(); + } + catch (err) { + console.log("TinyNode encountered an error trying to refresh its access token"); + console.error(err); + next(err); + } + })(); +} +