Skip to content

Commit

Permalink
Merge pull request #5 from nulib/5073-iiif-signed-urls
Browse files Browse the repository at this point in the history
Support signed URLs in IIIF requests
  • Loading branch information
mbklein authored Nov 6, 2024
2 parents 60ffbed + 10e07f1 commit 1325f78
Show file tree
Hide file tree
Showing 6 changed files with 1,412 additions and 16 deletions.
26 changes: 26 additions & 0 deletions iiif-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,29 @@ This will use the previously saved config file
- `Namespace`: The infrastructure namespace prefix to use for secrets management
- `ServerlessIiifVersion`: The version of Serverless IIIF to deploy (Default: `5.0.0`)
- `SourceBucket`: The bucket where the pyramid TIFF images are stored

## IIIF requests with signed urls

Optionally, the viewer function supports HMAC-signed URLs with an expires parameter.

- URL will take the form `https://iiif-server.domain/iiif/:version/:id/:region/:size/:rotation/:quality.:format?Auth-Signature=:jwt`
- JWT will be signed using symmetric HMAC encryption and the same key we currently use for the cookie auth JWTs
- JWT will have the following structure:
```javascript
{
"sub": "my-image-id", // image ID, required
"region": ["0,0,256,256"], // list of valid IIIF region values, optional
"size": ["pct:50"], // list of valid IIIF size values, optional
"rotation": ["0", "180", "!0"], // list of valid IIIF rotation values, optional
"quality": ["bitonal", "gray"], // list of valid IIIF quality values, optional
"format": ["jpg", "png"], // list of valid IIIF format values, optional
"max-width": 1024, // maximum width in pixels, optional
"max-height": 768 // maximum height in pixels, optional
"exp": 1687550764 // expiration timestamp, required
}
```
The request will be validated based on the fields present in the JWT.
- The `sub` field _must_ match the `:id` of the request.
- Any other IIIF spec field (`region`, `size`, `rotation`, `quality`, `format`), if present, will limit the valid values in the request. If the requested value does not appear in the list, the request will be denied. If a field is not present, any valid value is acceptable.
- If `max-width` and/or `max-height` is present, it will limit the size of the *full-frame image* that can be retrieved. That is, the authorizer will determine what the size of the whole image would be _after_ the region and size parameters are taken into account, and only authorize the request if both dimensions are less than or equal to `max-width` and `max-height`.
- The `exp` field indicates the time (expressed as seconds since the UNIX epoch) beyond which the signature is no longer valid.
79 changes: 64 additions & 15 deletions iiif-server/src/index.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
const authorize = require("./authorize");
const validateJwtClaims = require("./validate-jwt");
const jwt = require("jsonwebtoken");
const middy = require("@middy/core");
const secretsManager = require("@middy/secrets-manager");


function getEventHeader(request, name) {
if (
request.headers &&
Expand All @@ -14,6 +17,14 @@ function getEventHeader(request, name) {
}
}

function s3Location(params, bucket) {
const pairtree = params.id.match(/.{1,2}/g).join("/");

return params.poster
? `s3://${bucket}/posters/${pairtree}-poster.tif`
: `s3://${bucket}/${pairtree}-pyramid.tif`;
}

function viewerRequestOptions(request) {
const origin = getEventHeader(request, "origin") || "*";
return {
Expand Down Expand Up @@ -44,25 +55,66 @@ function parsePath(path) {
return {
poster: segments[2] == "posters",
id: segments[1],
filename: segments[0]
filename: segments[0],
version: segments[6]
};
} else {
const filename = segments[0].split(".");
return {
poster: segments[5] == "posters",
id: segments[4],
region: segments[3],
size: segments[2],
rotation: segments[1],
filename: segments[0]
filename: segments[0],
quality: filename[0],
format: filename[1],
version: segments[5]
};
}
}

function getAuthSignature(request) {
if (!request.querystring) {
return null;
}

const parsedQuery = new URLSearchParams(request.querystring);
return parsedQuery.get('Auth-Signature', null)
}

async function viewerRequestIiif(request, { config }) {
const path = decodeURI(request.uri.replace(/%2f/gi, ""));
const params = parsePath(path);
const referer = getEventHeader(request, "referer");
const cookie = getEventHeader(request, "cookie");
const authSignature = getAuthSignature(request);

if (authSignature) {
let jwtClaims;
try {
jwtClaims = jwt.verify(authSignature, config.apiTokenKey);
} catch (err) {
console.error(err)
return {
status: "403",
statusDescription: "Forbidden",
body: "Invalid JWT"
};
}

const jwtResult = await validateJwtClaims(jwtClaims, params, config);

if (!jwtResult.valid) {
console.log(`Could not verify JWT claims: ${jwtResult.reason}`);
return {
status: "403",
statusDescription: "Forbidden",
body: "Forbidden"
};
}
}

const authed = await authorize(
params,
referer,
Expand All @@ -82,12 +134,9 @@ async function viewerRequestIiif(request, { config }) {
}

// Set the x-preflight-location request header to the location of the requested item
const pairtree = params.id.match(/.{1,2}/g).join("/");
const s3Location = params.poster
? `s3://${config.tiffBucket}/posters/${pairtree}-poster.tif`
: `s3://${config.tiffBucket}/${pairtree}-pyramid.tif`;
const location = s3Location(params, config.tiffBucket);
request.headers["x-preflight-location"] = [
{ key: "X-Preflight-Location", value: s3Location }
{ key: "X-Preflight-Location", value: location }
];
return request;
}
Expand Down Expand Up @@ -152,14 +201,14 @@ function functionNameAndRegion() {
const { functionName, functionRegion } = functionNameAndRegion();
console.log("Initializing", functionName, 'in', functionRegion);

module.exports = {
module.exports = {
handler:
middy(processRequest)
.use(
secretsManager({
fetchData: { config: functionName },
awsClientOptions: { region: functionRegion },
setToContext: true
})
)
.use(
secretsManager({
fetchData: { config: functionName },
awsClientOptions: { region: functionRegion },
setToContext: true
})
)
};
Loading

0 comments on commit 1325f78

Please sign in to comment.