Service-to-Service Integration in My Apps
When a custom application deployed via My Apps needs to call an OSC catalog service (for example, to transcode a video, query a database, or call an API), it must present a Service Access Token (SAT) to the OSC ingress gate protecting that service. This page explains how to obtain a SAT from inside a running My App, how to cache it efficiently, and how to pass the target service URL to your code without hardcoding anything.
Already familiar with SATs? This page covers the My Apps-specific path where your app exchanges the
OSC_ACCESS_TOKENenvironment variable for a SAT. For the general developer reference on SATs, see Service Access Tokens.
How My Apps authenticate with OSC services
Every app deployed through the My Apps platform (Web Runner or Python Runner) automatically receives a long-lived runner refresh token in the OSC_ACCESS_TOKEN environment variable. This token is scoped to the tenant that owns the app; you never need to generate or paste one manually.
Because the SAT exchange endpoint requires a short-lived Personal Access Token (PAT), not the refresh token directly, the exchange is a two-step process:
- Exchange the runner refresh token for a short-lived PAT (1 hour TTL).
- Exchange the PAT for a SAT scoped to the target service.
After that, attach the SAT to every HTTP request to the target service.
The exchange flow
Step 1: Obtain a short-lived PAT
POST https://token.svc.prod.osaas.io/runner-token/refresh
Content-Type: application/json
{ "token": "<OSC_ACCESS_TOKEN>" }
Response:
{
"token": "<PAT>",
"expiresIn": 3600
}
expiresIn is the PAT lifetime in seconds (always 3600 at time of writing). Cache the PAT for this duration; re-exchange only when it is about to expire.
Step 2: Exchange the PAT for a SAT
POST https://token.svc.prod.osaas.io/servicetoken
x-pat-jwt: Bearer <PAT>
Content-Type: application/json
{ "serviceId": "eyevinn-some-service" }
Response:
{
"serviceId": "eyevinn-some-service",
"token": "<SAT>",
"expiry": 1753920000
}
expiry is a Unix timestamp (seconds). Cache the SAT until this time; re-exchange only when it has expired or you receive a 401 from the service.
Step 3: Call the target service
GET https://<instance-url>/some-endpoint
Authorization: Bearer <SAT>
The <instance-url> is the full URL of the specific service instance you want to reach. See Passing the target URL to your app below.
Passing the target URL to your app
Never hardcode a service instance URL. Instance URLs are generated at creation time and look like:
https://<tenant>-<instance>.<serviceId>.auto.prod.osaas.io
The recommended pattern is to store the URL in a parameter store and let the platform inject it as an environment variable at startup.
- Create a parameter store for your app (see Parameter Store Setup Guide).
- Find the instance URL in the OSC console or with the CLI:
npx @osaas/cli describe <serviceId> <instanceName>. - Store it as a parameter, for example
TARGET_SERVICE_URL, in your parameter store. - Wire your app to that parameter store via the ConfigService field when deploying.
- Read it in code as
process.env.TARGET_SERVICE_URL(Node.js) oros.environ["TARGET_SERVICE_URL"](Python).
Node.js example
This example implements a module-level cache for both the PAT and the SAT, so the exchange only runs when a token has expired.
// osc-auth.js
const TOKEN_SERVICE = 'https://token.svc.prod.osaas.io';
let patCache = null; // { token, expiresAt }
let satCache = {}; // { [serviceId]: { token, expiresAt } }
async function getPat() {
const now = Date.now();
if (patCache && now < patCache.expiresAt) {
return patCache.token;
}
const runnerToken = process.env.OSC_ACCESS_TOKEN;
if (!runnerToken) {
throw new Error('OSC_ACCESS_TOKEN is not set');
}
const res = await fetch(`${TOKEN_SERVICE}/runner-token/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: runnerToken })
});
if (!res.ok) {
throw new Error(`PAT exchange failed: ${res.status} ${await res.text()}`);
}
const data = await res.json();
// Refresh 60 seconds before expiry to avoid edge-case races
patCache = { token: data.token, expiresAt: now + (data.expiresIn - 60) * 1000 };
return patCache.token;
}
async function getSat(serviceId) {
const now = Date.now();
const cached = satCache[serviceId];
if (cached && now < cached.expiresAt) {
return cached.token;
}
const pat = await getPat();
const res = await fetch(`${TOKEN_SERVICE}/servicetoken`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-pat-jwt': `Bearer ${pat}`
},
body: JSON.stringify({ serviceId })
});
if (!res.ok) {
throw new Error(`SAT exchange failed: ${res.status} ${await res.text()}`);
}
const data = await res.json();
// data.expiry is a Unix timestamp in seconds; subtract 60 s for safety margin
satCache[serviceId] = {
token: data.token,
expiresAt: (data.expiry - 60) * 1000
};
return satCache[serviceId].token;
}
export { getSat };
Calling a service from your application code:
// app.js
import { getSat } from './osc-auth.js';
async function callTargetService() {
const serviceId = 'eyevinn-some-service'; // replace with actual serviceId
const serviceUrl = process.env.TARGET_SERVICE_URL;
const sat = await getSat(serviceId);
const res = await fetch(`${serviceUrl}/api/v1/data`, {
headers: { Authorization: `Bearer ${sat}` }
});
if (!res.ok) {
if (res.status === 401) {
// SAT may have expired mid-flight; clear the cache and retry once
delete satCache[serviceId];
return callTargetService();
}
throw new Error(`Service call failed: ${res.status}`);
}
return res.json();
}
Python example
This example follows the same caching approach using a simple module-level dictionary.
# osc_auth.py
import os
import time
import requests
TOKEN_SERVICE = "https://token.svc.prod.osaas.io"
_pat_cache = {} # keys: token, expires_at
_sat_cache = {} # keys: serviceId -> {token, expires_at}
def get_pat():
now = time.time()
if _pat_cache.get("token") and now < _pat_cache.get("expires_at", 0):
return _pat_cache["token"]
runner_token = os.environ.get("OSC_ACCESS_TOKEN")
if not runner_token:
raise RuntimeError("OSC_ACCESS_TOKEN is not set")
resp = requests.post(
f"{TOKEN_SERVICE}/runner-token/refresh",
json={"token": runner_token},
timeout=10,
)
resp.raise_for_status()
data = resp.json()
_pat_cache["token"] = data["token"]
_pat_cache["expires_at"] = now + data["expiresIn"] - 60
return _pat_cache["token"]
def get_sat(service_id: str) -> str:
now = time.time()
cached = _sat_cache.get(service_id, {})
if cached.get("token") and now < cached.get("expires_at", 0):
return cached["token"]
pat = get_pat()
resp = requests.post(
f"{TOKEN_SERVICE}/servicetoken",
headers={"x-pat-jwt": f"Bearer {pat}"},
json={"serviceId": service_id},
timeout=10,
)
resp.raise_for_status()
data = resp.json()
_sat_cache[service_id] = {
"token": data["token"],
"expires_at": data["expiry"] - 60,
}
return _sat_cache[service_id]["token"]
Calling a service:
# main.py
import os
import requests
from osc_auth import get_sat
SERVICE_ID = "eyevinn-some-service" # replace with actual serviceId
def call_target_service():
service_url = os.environ["TARGET_SERVICE_URL"]
sat = get_sat(SERVICE_ID)
resp = requests.get(
f"{service_url}/api/v1/data",
headers={"Authorization": f"Bearer {sat}"},
timeout=30,
)
resp.raise_for_status()
return resp.json()
Token caching guidance
The table below summarises the caching approach used in both examples above.
| Token | Lifetime | Cache key | When to re-exchange |
|---|---|---|---|
| PAT | expiresIn seconds (3600) |
Single value per process | At or after expiry (with 60 s safety margin) |
| SAT | Until expiry Unix timestamp |
One entry per serviceId |
At or after expiry, or on any 401 response |
Important: The 60-second safety margin in both examples prevents a token that is valid when fetched from expiring between the cache check and the actual service call. Adjust the margin if your service calls can take longer.
For multi-process deployments (multiple replicas of the same My App), each process maintains its own in-memory cache. This is fine; the token-service rate limits are generous and each process will exchange tokens independently without coordination overhead.
Tenant isolation
The OSC_ACCESS_TOKEN injected into your app is tied to the tenant that owns the app. A SAT issued from it is also tenant-scoped: it grants access only to service instances belonging to that same tenant. Your app cannot call service instances owned by other tenants, and no other tenant can use your app's OSC_ACCESS_TOKEN.
Related resources
- Service Access Tokens — General SAT reference for scripts, CLIs, and external applications
- Parameter Store Setup Guide — How to store and inject configuration (including service URLs) into My Apps
- User Guide: Managing Custom Apps — Restart, rebuild, and configuration update for deployed apps
- Service: Web Runner — Node.js app hosting on OSC
- Service: Python Runner — Python app hosting on OSC