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
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)!

Expand Down
11 changes: 10 additions & 1 deletion openapi/components/tinynode-shared-components.openapi.yaml
Original file line number Diff line number Diff line change
@@ -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/
Expand All @@ -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
Expand Down
63 changes: 63 additions & 0 deletions passthrough.js
Original file line number Diff line number Diff line change
@@ -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())
}
16 changes: 13 additions & 3 deletions routes/create.js
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -21,14 +22,23 @@ 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"
}
}
const createURL = `${process.env.RERUM_API_ADDR}create`
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 {
Expand Down
29 changes: 24 additions & 5 deletions routes/delete.js
Original file line number Diff line number Diff line change
@@ -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)")
Expand All @@ -20,14 +21,23 @@ 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"
}
}
const deleteURL = `${process.env.RERUM_API_ADDR}delete`
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()}`
Expand All @@ -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}`
Expand All @@ -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 {
Expand Down
14 changes: 12 additions & 2 deletions routes/overwrite.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand All @@ -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"
}
}
Expand Down Expand Up @@ -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 {
Expand Down
14 changes: 12 additions & 2 deletions routes/update.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -20,14 +21,23 @@ 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"
}
}
const updateURL = `${process.env.RERUM_API_ADDR}update`
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 {
Expand Down
6 changes: 5 additions & 1 deletion sample.env
Original file line number Diff line number Diff line change
Expand Up @@ -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
#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
35 changes: 35 additions & 0 deletions test/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions test/helpers/env.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading
Loading