TEMS Integration Guide — Publishing an App in the Dataspace
:::info Audience App owners (Media Providers, Source Explorer operators, any T&I team) who want to publish their web application or BFF as a TEMS offering. Stack-agnostic: works for Python, Java/Kotlin, Node/TypeScript backends. :::
TL;DR: To publish your app in TEMS:
- Deploy your service single-origin (front + API behind one URL).
- Use relative URLs in your SPA's code (
fetch('/api/...'), neverhttps://api.elsewhere.com). - Make your SPA basePath-aware so it can be served from
/proxy/{session_id}/.... - Drop an nginx snippet in front of your backend to translate
X-Dataspace-Identity→Authorization: Bearer. - Add a DID:web JWT validator to your backend (~150 lines, one per stack). The gateway signs user JWTs with the participant's DID-bound key; your backend validates the signature via DID:web resolution.
A reference implementation lives in the tems-gateway repository under examples/demo-spa/. The Source Explorer BFF (Python) ships a production-grade DID:web validator at source-explorer-backend/api/src/commons/infrastructure/auth/did_web_jwt_validator.py.
1. Why this guide exists
A TEMS-published SPA is accessed in two distinct modes:
Mode direct — what your app sees today, no gateway involved :
Mode TEMS — the same app, now reached through the gateway :
Same SPA bundle in both modes. The differences:
- The URL the user sees (and therefore the basePath the SPA must adopt).
- The identity issuer the backend validates against (your KC in direct mode; the consumer's
did:web:<participant>in TEMS mode; both can coexist behind a dispatch oniss, see §4.5). - The per-request policy enforcement that happens transparently in TEMS mode (handled by the Gateway + EDC data plane, invisible to the application).
The contract: you write your application normally. TEMS adapts to your application, not the other way around.
The adaptations required are mechanical: basePath, relative URLs, an nginx snippet, and a DID:web JWT validator (the only piece of code TEMS asks you to add, about 100-150 lines per stack). Your business logic stays untouched.
2. The constraints, in one paragraph
The Gateway is a reverse HTTP proxy. It can only enforce dataspace policy on requests that flow through it. If a single request bypasses the Gateway (e.g. your SPA fetches https://api.elsewhere.com/users directly), that request escapes ODRL evaluation and the dataspace contract is broken. Therefore: every first-party request your SPA makes must transit through the Gateway, which means everything must live on a single origin (the URL you publish in your offering), reachable via relative paths only from the SPA's perspective.
Third-party requests (analytics, public CDN libraries, fonts) are unaffected — they're outside your service boundary.
3. The three adaptations
3.1 Single-origin deployment
Your service must be reachable behind one URL. Front and API on the same origin. Common patterns:
- Next.js / Nuxt / Remix / SvelteKit full-stack — already single-origin by design.
- React + REST API behind an nginx that routes
/→ static bundle,/api/*→ backend. - BFF pattern (a thin backend that serves both HTML and proxies to inner microservices).
If your front and API are on separate domains today (e.g. https://my-spa.com and https://api.my-spa.com), you must consolidate them behind a reverse proxy or BFF before publishing in TEMS.
3.2 Relative URLs in your SPA
Inside your SPA's JavaScript, never hardcode absolute URLs to first-party services:
// ❌ Don't:
fetch('https://api.my-spa.com/users')
fetch('/api/users', { mode: 'cors' }) // explicit cross-origin
// ✅ Do:
fetch('/api/users')
fetch('./users', { /* relative to current page path */ })
Same rule for <img src> / <link href>, dynamic imports, WebSocket URLs, etc.
When the SPA is loaded via https://gateway.../proxy/{sid}/index.html:
fetch('/api/users')resolves tohttps://gateway.../api/users— wrong, escapes the proxy prefix.fetch('api/users')resolves tohttps://gateway.../proxy/{sid}/api/users— correct, proxied.
Best practice: use a basePath-aware fetch helper (see §3.3).
3.3 BasePath-aware routing
Your SPA may be mounted at / (mode direct) or at /proxy/{session_id}/ (mode TEMS). Detect at runtime:
// At application bootstrap
const TEMS_PREFIX_RE = /^\/proxy\/([^/]+)\//;
const match = window.location.pathname.match(TEMS_PREFIX_RE);
const basePath = match ? `/proxy/${match[1]}` : '';
const isTemsMode = match !== null;
Then configure your router:
// React Router
<BrowserRouter basename={basePath}>...</BrowserRouter>
// Vue Router
createRouter({ history: createWebHistory(basePath), ... })
// Next.js — set NEXT_PUBLIC_BASE_PATH at build OR use rewrites
// Nuxt — `app.baseURL` runtime config
And your fetch helper:
function api(path, init) {
return fetch(`${basePath}${path.startsWith('/') ? path : '/' + path}`, init);
}
// Usage
api('/users') // → /proxy/{sid}/users in TEMS mode, /users in direct mode
api('/api/orders') // → /proxy/{sid}/api/orders in TEMS mode, /api/orders in direct mode
4. Authentication: validate a DID-signed JWT
This is the part that matters most. The gateway forges the user-identity JWT with the consumer participant's EC P-256 key (iss=did:web:<participant>, kid=<key-id>, alg=ES256). Your backend validates the signature via DID:web resolution, not against a fixed JWKS URL.
4.1 The flow
TEMS mode — Gateway forges a DID-signed JWT, data plane proxies through, edge nginx rewrites onto the standard Authorization slot :
Direct mode — same backend, plain KC JWKS validation :
The gateway-forged JWT carries the standard claims your app expects (sub, preferred_username, etc.) — the validation strategy is the only thing that changes between modes.
4.2 The edge nginx rewrite
The Gateway, when proxying through EDC's data plane, can't put the user identity in Authorization (the EDR token already occupies it; RFC 9110 §11 forbids two Authorization headers). So it injects the user JWT in X-Dataspace-Identity. Right before the request reaches your backend, an edge nginx (or Traefik / Caddy / Apache equivalent) rewrites it back:
server {
listen 8000;
location / {
# If X-Dataspace-Identity is set (TEMS mode), promote it to Authorization.
# Otherwise the original Authorization (direct mode) is preserved.
set $auth $http_authorization;
if ($http_x_dataspace_identity != "") {
set $auth "Bearer $http_x_dataspace_identity";
}
proxy_set_header Authorization $auth;
proxy_set_header X-Dataspace-Identity ""; # don't forward to backend; it doesn't need it
proxy_pass http://your-backend:8080;
}
}
Drop this in front of your backend. Stack-agnostic.
Equivalent Traefik config (middleware):
http:
middlewares:
tems-auth-rewrite:
plugin:
rewriteHeaders:
rewrites:
- header: Authorization
regex: ".*"
replacement: "Bearer {{ index .Headers \"X-Dataspace-Identity\" 0 }}"
condition: "{{ index .Headers \"X-Dataspace-Identity\" 0 }} != \"\""
(Use the official headers middleware for production; the snippet above is illustrative.)
4.3 DID:web validation in your backend
Your backend needs a small validator (about 100-150 lines). The algorithm is the same for every stack:
- Read
iss(unverified) andkidfrom the JWT. - Check
issis in your trusted-issuers allow-list (the list of consumer DIDs whose tokens you accept). Without this gate, anyone who can host adid.jsonon a reachable URL could forge tokens for your app. - Resolve
did:web:<host>[:<port>][:<path>...]to a URL:did:web:example.com→https://example.com/.well-known/did.jsondid:web:example.com%3A8443→https://example.com:8443/.well-known/did.jsondid:web:example.com:user:alice→https://example.com/user/alice/did.json
- Fetch the DID document (cache the result; TTL ~10 min is fine since DIDs rotate rarely).
- Find the
verificationMethodwhoseidends with the JWT'skid(fragment-compared:<did>#key-1matches a JWT withkid=key-1). - Build an EC public key from the entry's
publicKeyJwkand verify the ES256 signature.
Python (PyJWT + cryptography + httpx)
Reference implementation in production: source-explorer-backend/api/src/commons/infrastructure/auth/did_web_jwt_validator.py. Compressed sketch:
# pip install httpx pyjwt cryptography
from urllib.parse import unquote
import httpx, jwt
from jwt import PyJWK
def _did_to_url(did: str) -> str:
parts = [unquote(s) for s in did.removeprefix("did:web:").split(":")]
host_port, *path = parts
base = f"https://{host_port}"
return f"{base}/{'/'.join(path)}/did.json" if path else f"{base}/.well-known/did.json"
def validate_tems_jwt(token: str, *, trusted_issuers: set[str], audience: str | None = None) -> dict:
header = jwt.get_unverified_header(token)
unverified = jwt.decode(token, options={"verify_signature": False})
if unverified.get("iss") not in trusted_issuers:
raise ValueError("untrusted issuer")
did_doc = httpx.get(_did_to_url(unverified["iss"]), timeout=5.0).raise_for_status().json()
fragment = header["kid"].split("#")[-1]
method = next(m for m in did_doc["verificationMethod"]
if m["id"].split("#")[-1] == fragment)
key = PyJWK.from_dict(method["publicKeyJwk"]).key
options = {"verify_aud": False} if not audience else {}
return jwt.decode(token, key, algorithms=["ES256"],
issuer=unverified["iss"], audience=audience, options=options)
Production code adds a TTL cache for the DID document, single-flight protection, an insecure-hosts allow-list for local dev (HTTP instead of HTTPS), and per-host scheme selection. See the Source Explorer reference impl.
Java / Kotlin (nimbus-jose-jwt + java.net.http)
// build.gradle.kts:
// implementation("com.nimbusds:nimbus-jose-jwt:9.40")
// implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.17.0")
import com.fasterxml.jackson.databind.ObjectMapper
import com.nimbusds.jose.crypto.ECDSAVerifier
import com.nimbusds.jose.jwk.ECKey
import com.nimbusds.jwt.SignedJWT
import java.net.URI
import java.net.URLDecoder
import java.net.http.*
private val mapper = ObjectMapper()
private val httpClient = HttpClient.newHttpClient()
private fun didToUrl(did: String): String {
val parts = did.removePrefix("did:web:")
.split(":")
.map { URLDecoder.decode(it, Charsets.UTF_8) }
val (hostPort, pathParts) = parts.first() to parts.drop(1)
val base = "https://$hostPort"
return if (pathParts.isEmpty()) "$base/.well-known/did.json"
else "$base/${pathParts.joinToString("/")}/did.json"
}
fun validateTemsJwt(token: String, trustedIssuers: Set<String>, audience: String? = null): Map<String, Any> {
val jwt = SignedJWT.parse(token)
val claims = jwt.jwtClaimsSet
val iss = claims.issuer ?: error("missing iss")
require(iss in trustedIssuers) { "untrusted issuer" }
val req = HttpRequest.newBuilder(URI(didToUrl(iss))).timeout(java.time.Duration.ofSeconds(5)).build()
val didDoc = mapper.readTree(httpClient.send(req, HttpResponse.BodyHandlers.ofString()).body())
val fragment = jwt.header.keyID.substringAfterLast("#")
val method = didDoc["verificationMethod"]
.first { it["id"].asText().substringAfterLast("#") == fragment }
val jwk = ECKey.parse(mapper.writeValueAsString(method["publicKeyJwk"]))
require(jwt.verify(ECDSAVerifier(jwk))) { "signature verification failed" }
require(claims.expirationTime.after(java.util.Date())) { "expired" }
audience?.let { require(it in claims.audience) { "wrong audience" } }
return claims.claims
}
Spring Boot apps can wrap this in a JwtDecoder bean for Spring Security integration.
Node / TypeScript (jose)
// npm install jose
import { jwtVerify, importJWK, decodeJwt, decodeProtectedHeader, type JWK } from 'jose';
function didToUrl(did: string): string {
const parts = did.replace(/^did:web:/, '').split(':').map(decodeURIComponent);
const [hostPort, ...path] = parts;
const base = `https://${hostPort}`;
return path.length ? `${base}/${path.join('/')}/did.json`
: `${base}/.well-known/did.json`;
}
export async function validateTemsJwt(
token: string,
trustedIssuers: Set<string>,
audience?: string,
): Promise<Record<string, unknown>> {
const header = decodeProtectedHeader(token);
const unverified = decodeJwt(token);
if (!trustedIssuers.has(unverified.iss ?? '')) {
throw new Error('untrusted issuer');
}
const didDoc = await fetch(didToUrl(unverified.iss!)).then(r => r.json());
const fragment = (header.kid ?? '').split('#').pop();
const method = didDoc.verificationMethod.find(
(m: { id: string }) => m.id.split('#').pop() === fragment,
);
if (!method) throw new Error('no verificationMethod for kid');
const key = await importJWK(method.publicKeyJwk as JWK, 'ES256');
const { payload } = await jwtVerify(token, key, {
issuer: unverified.iss,
audience,
});
return payload as Record<string, unknown>;
}
In Express/Fastify, wrap this in a middleware that reads Authorization: Bearer ... and attaches the validated claims to req.user.
4.4 Configure the trusted-issuers allow-list
The trust anchor is configuration, not code. Each consumer participant has a DID (e.g. did:web:rai.tems-dataspace.eu). Your backend trusts a static list:
USER_JWT_TRUSTED_ISSUERS=did:web:rai.tems-dataspace.eu,did:web:ard.tems-dataspace.eu
USER_JWT_AUDIENCE= # leave empty; signed_claim JWTs have no aud
DID_RESOLUTION_INSECURE_HOSTS= # production: empty (HTTPS only)
When a new consumer joins the dataspace, add their DID to the allow-list. One config line per consumer; no JWKS URL to host, no code change.
For local dev against the EBU stack, the consumer DID is did:web:host.docker.internal%3A8081; opt the local host into HTTP resolution with DID_RESOLUTION_INSECURE_HOSTS=host.docker.internal.
4.5 Dual-mode (your app outside TEMS too)
If your app must also work outside TEMS (a user hitting it directly with a JWT from your own KC), keep your existing KC-based validation alongside the DID:web validator. Dispatch on iss: if it starts with did:web:, use the DID validator; otherwise fall back to your KC JWKS validator. No request needs both validators to run.
5. The TEMS-readiness checklist
Before publishing your service in TEMS, verify:
- My application is deployed on a single origin (front + API behind one URL).
- My SPA's code uses relative URLs for all first-party requests (no hardcoded
https://api.elsewhere.com). - My SPA's router supports a dynamic basePath computed from
window.location.pathname. - My static assets (JS, CSS, images, fonts) load with relative paths or from third-party CDNs (not from a first-party CDN on a different domain).
- An edge proxy in front of my backend rewrites
X-Dataspace-Identity→Authorization: Bearer(nginx snippet in §4.2, or equivalent). - My backend has a DID:web JWT validator (per-stack snippets in §4.3) and a trusted-issuers allow-list configured (§4.4).
- My backend rejects JWTs whose
issis not in the allow-list, even if their signature would otherwise verify (the issuer check is the trust anchor; do not skip it). - If I run dual-mode, my code dispatches between the DID validator and the KC validator on
iss(§4.5). - I have tested my SPA running locally under a fake
/proxy/test-sid/prefix to verify nothing breaks. - I have no WebSocket / SSE traffic that would need to traverse the Gateway. (Or, if I do, I have validated that my Gateway version supports it; currently not in P.6 scope.)
6. Reference implementation
The tems-gateway repository ships a fully working minimal example under examples/demo-spa/. It demonstrates the canonical 3-container production shape:
edge (nginx) ── /api/* ──→ backend (FastAPI, API only)
── /* ──→ frontend (nginx + Vite dist, SPA fallback)
- A Vite + React + TypeScript SPA with two routes (
/,/content/:id) that detects its mode at runtime and adapts router basePath + fetch helper. - A FastAPI backend exposing only
/api/*— no static-serving plumbing. It validatesAuthorization: Bearer <JWT>against ONE JWKS. - A frontend nginx serving the Vite-built bundle with SPA fallback.
- An edge nginx routing
/api/*→ backend (with theX-Dataspace-Identity→Authorizationrewrite) and/*→ frontend. - A debug drawer in the SPA showing the detected mode, basePath, and decoded identity.
- A
docker-compose.ymlwiring the three services together.
Why three containers and not one? Because that's how anyone deploys this in real life — a static host (CDN/nginx/Vercel) for the SPA, a separate API process, an edge for the single-origin requirement. The demo mirrors what SPA owners actually run.
Run cd examples/demo-spa && docker compose up --build from the tems-gateway checkout and read the README.
7. FAQ
Q: My SPA uses Service Workers / PWA features. Can I still publish in TEMS?
A: Service Workers are scoped to an origin + path. Under /proxy/{sid}/..., your SW would be scoped to that path. Cache, intercepts, push notifications — they all need to be path-aware. Test thoroughly.
Q: My backend uses cookies for session management. Will they work?
A: Yes. The Gateway strips the Domain= attribute on Set-Cookie so cookies stay scoped to the Gateway origin (the user's perspective). Path-scoping (/proxy/{sid}/...) works naturally.
Q: Can I ship the edge nginx as part of my Docker image, instead of a separate container?
A: Yes. The 3-line snippet works inside the same container that runs your backend. Just have nginx listen on port 8000, your backend on 8080, and proxy_pass between them.
Q: What if I cannot or do not want to add an edge proxy?
A: Then your backend will need to read identity from X-Dataspace-Identity directly. That's a small middleware change in your backend code (a dependency injection or interceptor that copies X-Dataspace-Identity into the standard Authorization slot before your auth logic runs). Functionally equivalent to the nginx approach, but moves the responsibility into your codebase.
Q: How do I get an agreement_id for testing?
A: Through the dataspace consumer dashboard (e.g. connectors-frontend in consumer mode): browse the federated catalogue, select an offering, acquire it. The agreement appears in your acquisitions list.
Q: Does this guide apply to backend-only services (no SPA, just an API)?
A: Mostly yes. Skip the SPA-side adaptations (basePath, relative URLs). The nginx edge and DID:web validation requirements remain. Single-origin is trivially satisfied.
Q: Why a JWT validator instead of trusting a JWKS URL like a normal OAuth backend?
A: The gateway signs with the participant's DID-bound key (the same key the participant's Identity Hub publishes in its /.well-known/did.json). Hosting a per-consumer JWKS URL would require every provider to maintain a list of per-consumer URLs (O(N) configuration per provider, fragile). DID:web resolution lets every provider use the same algorithm: read iss, resolve the DID, pick the matching verificationMethod. Adding a new consumer is one allow-list entry, no infrastructure.
Q: Can I depend on a TEMS library instead of writing my own validator?
A: Not yet. As of this writing, the only reference implementation is in Python (source-explorer-backend). The per-stack snippets in §4.3 are intentionally compact so you can copy-paste and adapt them. A future shared library is on the T&I roadmap once we have a second non-Python adopter.
Q: How does P.7 refresh-token interact with DID-keyed signing?
A: P.7 refreshes the incoming user bearer (the user's KC token, signed by KC-Consumer). DID-keyed signing produces the outgoing JWT (signed by the gateway with the participant's DID key). The two are on different layers and orthogonal: when the KC bearer expires, the gateway refreshes it transparently and re-forges a fresh DID-signed JWT for the next proxy hit. Your backend never sees the refresh.
8. References
- Source Explorer — Ingestion API — the EDR-native + OAuth2 endpoint a Provider pushes enriched TemsCore into.
- Local Indexer — Indexing API — the EBU-hosted enrichment pipeline.
- TemsCore vocabulary registry — the public schema for the payloads exchanged across the dataspace.
- DID:web specification — w3c-ccg.github.io/did-method-web.
- The gateway repository's own
docs/(architecture, EDC flows, ADRs) lives intems-gatewayand is internal to the consortium.