How to Build Apps That Act on Behalf of OSC Users
Most SaaS platforms force a single-tenant model: you deploy your app, your users connect to it, and everything runs inside your account. Your API key, your quotas, your bill. If something goes wrong, every user is affected at once.
OSC takes a different approach. You can build apps where each visitor signs in with their own OSC account and provisions resources inside their own workspace. The infrastructure costs come out of their plan. Their data stays isolated. You just write the app.
Two production examples already do this:
- Open Intercom (source, live) lets visitors deploy cloud intercom systems in their own OSC workspace.
- Open Media Convert (source, live) lets visitors transcode video using OSC services provisioned under their own account.
Both use the same pattern: OAuth 2.0 with PKCE, yielding a Personal Access Token (PAT) scoped to the user's own tenant. This post shows you exactly how to implement it.
The pattern in one paragraph
Your app redirects the user to OSC's authorization endpoint. The user signs in (if they're not already) and clicks "Allow." OSC redirects back to your callback URL with a short-lived code. Your server exchanges that code for an access_token and a refresh_token. The access_token is a standard OSC PAT, valid for 3600 seconds. You pass it as a Bearer token when calling OSC APIs, and every resource created goes into that user's workspace, not yours.
PKCE (Proof Key for Code Exchange) is mandatory. There is no option to skip it.
Step 1: Register your OAuth app
Go to app.osaas.io, sign in, open My Apps, and click the OAuth Apps tab. Create an app and copy the client_secret immediately. It is shown only once.
You get a client_id in the format osc_<24chars> and a client_secret in the format osc_secret_<64hex>.
Both example apps support a fallback: if CLIENT_ID and CLIENT_SECRET are not set as environment variables, they register a client dynamically at runtime using POST /api/connect/register. This is useful during development but you should pre-register for production deployments.
Step 2: Generate PKCE and redirect the user
This is the real auth.ts from both Open Intercom and Open Media Convert:
import crypto from "node:crypto";
export function generatePKCE(): { codeVerifier: string; codeChallenge: string } {
const codeVerifier = crypto.randomBytes(32).toString("base64url");
const codeChallenge = crypto
.createHash("sha256")
.update(codeVerifier)
.digest("base64url");
return { codeVerifier, codeChallenge };
}
export function generateState(): string {
return crypto.randomBytes(16).toString("base64url");
}
export async function buildAuthorizationUrl(
clientId: string,
redirectUri: string,
codeChallenge: string,
state: string,
): Promise<string> {
const params = new URLSearchParams({
response_type: "code",
client_id: clientId,
redirect_uri: redirectUri,
code_challenge: codeChallenge,
code_challenge_method: "S256",
state,
});
return `https://app.osaas.io/api/connect/authorize?${params.toString()}`;
}
The sign-in route stores the PKCE verifier and state in the server-side session before redirecting:
app.get("/auth/signin", async (req, res) => {
const redirectUri = getRedirectUri(req); // uses x-forwarded-proto/host
// Use pre-registered credentials or fall back to dynamic registration
if (process.env.CLIENT_ID && process.env.CLIENT_SECRET) {
req.session.clientId = process.env.CLIENT_ID;
req.session.clientSecret = process.env.CLIENT_SECRET;
} else {
const client = await registerClient(redirectUri);
req.session.clientId = client.client_id;
req.session.clientSecret = client.client_secret;
}
const { codeVerifier, codeChallenge } = generatePKCE();
const state = generateState();
req.session.codeVerifier = codeVerifier; // never sent to browser
req.session.oauthState = state;
const authUrl = await buildAuthorizationUrl(
req.session.clientId!,
redirectUri,
codeChallenge,
state,
);
req.session.save(() => res.redirect(authUrl));
});
The user lands on OSC's consent page, which shows your app's name and the permissions it is requesting (create, modify, and remove service instances). If they are not signed in to OSC yet, they see the login page first and are then forwarded automatically.
Step 3: Exchange the code for a PAT
OSC redirects back to your redirect_uri with ?code=XXX&state=YYY. Verify the state, then exchange the code:
app.get("/auth/callback", async (req, res) => {
const { code, state } = req.query;
if (state !== req.session.oauthState) {
res.status(400).send("Invalid state parameter");
return;
}
const { codeVerifier, clientId, clientSecret } = req.session;
const redirectUri = getRedirectUri(req);
const tokenResponse = await exchangeCode(
code as string,
codeVerifier!,
clientId!,
clientSecret,
redirectUri,
);
req.session.accessToken = tokenResponse.access_token;
if (tokenResponse.refresh_token) {
req.session.refreshToken = tokenResponse.refresh_token;
}
if (tokenResponse.expires_in) {
// 60-second buffer so you refresh before the token actually expires
req.session.tokenExpiresAt = Date.now() + (tokenResponse.expires_in - 60) * 1000;
}
delete req.session.codeVerifier;
delete req.session.oauthState;
req.session.save(() => res.redirect("/"));
});
The exchangeCode function posts to https://app.osaas.io/api/connect/token:
export async function exchangeCode(
code: string,
codeVerifier: string,
clientId: string,
clientSecret: string | undefined,
redirectUri: string,
): Promise<TokenResponse> {
const body: Record<string, string> = {
grant_type: "authorization_code",
code,
redirect_uri: redirectUri,
client_id: clientId,
code_verifier: codeVerifier,
};
if (clientSecret) body.client_secret = clientSecret;
const res = await fetch("https://app.osaas.io/api/connect/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(body).toString(),
});
if (!res.ok) throw new Error(`Token exchange failed (${res.status})`);
return res.json();
}
The response looks like:
{
"access_token": "...",
"refresh_token": "...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "claudeai"
}
Store both tokens server-side only. Never write them into HTML, cookies accessible to JavaScript, or AI chat prompts (they would be stored in LLM conversation history).
Step 4: Call OSC APIs as the user
The access_token is a standard OSC PAT. Open Intercom uses the @osaas/client-core SDK to list and provision intercom instances in the user's workspace:
import { Context, listInstances } from "@osaas/client-core";
const ctx = new Context({ personalAccessToken: req.session.accessToken });
const sat = await ctx.getServiceAccessToken("eyevinn-intercom-manager");
const instances = await listInstances(ctx, "eyevinn-intercom-manager", sat);
Open Media Convert goes further. It provisions MinIO, SVT Encore, Valkey, and a profile server for the user's transcoding pipeline:
const ctx = new Context({ personalAccessToken: req.session.accessToken });
const minioInstance = await createInstance(ctx, "minio-minio", {
name: "encoreshared",
});
await waitForInstanceReady(ctx, "minio-minio", "encoreshared");
Every instance created through the user's PAT appears in their OSC dashboard and draws from their token plan. You are not the bottleneck.
For direct HTTP calls to deploy-manager or money-manager, pass the token via the x-pat-jwt header:
const response = await fetch(
"https://deploy.svc.prod.osaas.io/terraform/mydeployments",
{
headers: { "x-pat-jwt": `Bearer ${req.session.accessToken}` },
}
);
Handling token refresh
Open Media Convert includes an ensureValidToken middleware that refreshes transparently before each protected API call. Transcoding jobs can run for several minutes, longer than the 3600-second token lifetime:
async function ensureValidToken(req: express.Request): Promise<boolean> {
if (!req.session.accessToken) return false;
const now = Date.now();
if (req.session.tokenExpiresAt && now >= req.session.tokenExpiresAt) {
try {
const tokenResponse = await refreshAccessToken(
req.session.refreshToken!,
req.session.clientId!,
req.session.clientSecret,
);
req.session.accessToken = tokenResponse.access_token;
if (tokenResponse.refresh_token) {
req.session.refreshToken = tokenResponse.refresh_token;
}
if (tokenResponse.expires_in) {
req.session.tokenExpiresAt =
Date.now() + (tokenResponse.expires_in - 60) * 1000;
}
return true;
} catch {
delete req.session.accessToken;
delete req.session.refreshToken;
return false;
}
}
return true;
}
The refresh grant is a simple POST with grant_type=refresh_token and the stored refresh_token. No code_verifier needed. You get a new access_token and a new refresh_token back. Replace both.
What you can do with the PAT
Once you have a PAT scoped to the user's workspace, you can:
- Create and delete any service instance (databases, media services, CouchDB, Valkey, MinIO, and 40+ more)
- Read instance status, endpoints, and configuration
- Deploy apps from GitHub repos via My Apps
- Check the user's plan and remaining token balance
- Provision full Terraform-defined solutions in one call
All of it runs in the user's workspace. They can see what your app created, manage it themselves, and delete it when they are done.
Try it, fork it, build your own
The two live examples are the fastest way to see this working end-to-end.
Visit Open Intercom to sign in with an OSC account and deploy a cloud intercom system in your own workspace. The whole app is on GitHub with no magic. The src/auth.ts and src/server.ts files are the entire OAuth implementation, about 150 lines total.
Visit Open Media Convert to see token refresh in action during a longer transcoding session. Same repo pattern, adds the ensureValidToken middleware.
The full developer reference, including the exact endpoint contracts and security checklist, is at docs.osaas.io.
If you want to build your own OSC-powered app, start at osaas.io. The OAuth Apps tab is under My Apps once you have a paid plan.