Skip to content

Authenticated Sources

Open OME-ZARR images that live in private storage (S3 buckets, on-prem servers, lab NAS). The viewer ships hooks for the two common cases:

  1. Static credentials: a Bearer token, basic auth header, or any custom header. Use this for servers that accept a long-lived credential per request.
  2. Per-request signing: AWS SigV4 (or any signature scheme) for endpoints where every request needs its own cryptographic signature.

Both plug into the same HttpZarrStore. No store sub-classing, no monkey-patching.

Static Headers

For servers that accept a fixed Authorization header on every request:

ts
import { HttpZarrStore, OmeZarrParser } from '@find-nuclei/viewer';

const store = new HttpZarrStore('https://lab-nas.example.com/data.zarr', {
    headers: { Authorization: 'Bearer <token>' },
});
const parser = new OmeZarrParser(null, store);

Headers are merged into every fetch the store makes: metadata files, range requests, chunk GETs.

AWS SigV4 (S3-direct via STS)

For S3 buckets where you want the viewer to fetch tiles directly from S3 without proxying every request through a server, use STS-scoped temporary credentials.

The viewer doesn't talk to AWS or know about IAM. Your backend mints short-lived credentials scoped to one image's S3 prefix; the viewer signs each request with them.

Backend contract

Your backend (any language, any framework) exposes an endpoint that:

  1. Authenticates the user (your auth, not the viewer's concern).
  2. Verifies the user has permission for the image.
  3. Calls sts:AssumeRole with an inline session policy limited to the one image's S3 prefix.
  4. Returns the temporary credentials.

Example response shape (15-min TTL is the STS minimum):

json
{
    "access_key_id":     "ASIAEXAMPLE...",
    "secret_access_key": "...",
    "session_token":     "...",
    "expires_at":        "2026-04-25T19:50:38+00:00",
    "region":            "eu-central-1",
    "bucket":            "my-private-bucket",
    "prefix":            "orgs/abc/images/xyz/"
}

The session policy on the backend should be something like:

json
{
    "Statement": [{
        "Effect":   "Allow",
        "Action":   ["s3:GetObject"],
        "Resource": ["arn:aws:s3:::my-private-bucket/orgs/abc/images/xyz/*"]
    }]
}

A leaked token can only read the one image's tiles. It cannot list the bucket and cannot read other images.

Viewer setup

ts
import {
    HttpZarrStore,
    OmeZarrParser,
    createAwsSigner,
} from '@find-nuclei/viewer';

const initial = await fetch(`/api/v1/images/${imageId}/access-token`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${cognitoJwt}` },
}).then(r => r.json());

// Hold the latest creds in a closure so refresh swaps them in place.
let creds = initial;
let pendingRefresh = null;

const REFRESH_AHEAD_MS = 2 * 60 * 1000;

const refreshIfNeeded = async () => {
    const remaining = new Date(creds.expires_at).getTime() - Date.now();
    if (remaining > REFRESH_AHEAD_MS) return creds;
    if (pendingRefresh) return pendingRefresh;
    pendingRefresh = (async () => {
        const fresh = await fetch(`/api/v1/images/${imageId}/access-token`, {
            method: 'POST',
            headers: { Authorization: `Bearer ${cognitoJwt}` },
        }).then(r => r.json());
        creds = fresh;
        return creds;
    })().finally(() => { pendingRefresh = null; });
    return pendingRefresh;
};

const signer = createAwsSigner({
    getCredentials: async () => {
        const c = await refreshIfNeeded();
        return {
            accessKeyId:     c.access_key_id,
            secretAccessKey: c.secret_access_key,
            sessionToken:    c.session_token,
            expiresAt:       c.expires_at,
            region:          c.region,
        };
    },
});

const directUrl =
    `https://${initial.bucket}.s3.${initial.region}.amazonaws.com/` +
    initial.prefix.replace(/\/$/, '');

const store = new HttpZarrStore(directUrl, {
    signRequest: signer.signRequest,
});
const parser = new OmeZarrParser(null, store);

After this, every fetch() the store issues is signed with SigV4 in the browser using crypto.subtle (native, hardware-accelerated). That covers .zattrs, .zarray, .zgroup, every chunk, and every range request. No proxy server sits in the per-tile path.

Refresh strategy

Refresh is request-driven, not timer-driven:

  • Each request calls signer.signRequest, which calls getCredentials.
  • getCredentials checks how much TTL is left and returns cached creds if comfortable, or triggers one mint call if not.
  • Concurrent requests during a refresh share the same in-flight promise (the signer dedups internally as a second layer of safety).

No setInterval, no background work. If the user idles for 20 minutes, no API calls happen; the next interaction triggers exactly one refresh.

Custom signers

signRequest accepts any callback of shape:

ts
type SignRequest = (
    url: string,
    init: RequestInit,
) => Promise<{ url: string; init: RequestInit }>;

The store calls it before every fetch, gets back a possibly-rewritten URL and RequestInit, and uses those. Use this for:

  • HMAC-signed URLs (custom auth schemes)
  • CloudFront signed URLs that you generate per request
  • Signed cookies (return init.credentials = 'include' and let the browser send the cookie)
  • Anything else where the auth depends on the request itself

CORS

Whichever auth scheme you use, the underlying server has to allow the viewer's origin. For S3, that means the bucket's CORS policy must allow GET from your viewer host and expose Content-Length / Content-Range headers (the latter is required for ZARR sharded reads via byte ranges).

A working S3 CORS policy:

json
[{
    "AllowedOrigins": ["https://your-app.example.com"],
    "AllowedMethods": ["GET"],
    "AllowedHeaders": ["*"],
    "ExposeHeaders": ["Content-Length", "Content-Range", "ETag"],
    "MaxAgeSeconds": 3000
}]

Free. Private. Browser-based.