Act on Behalf of a User (OAuth)

OSC provides an OAuth 2.0 authorization flow that lets third-party applications authenticate OSC users and then call OSC platform APIs inside each user's own workspace. The resulting token is a Personal Access Token (PAT) scoped to that user's workspace. No shared credentials are needed.

When to Use This

Approach Best for
Personal PAT (from dashboard Settings / API) Your own scripts and local tooling
Platform API key Server-to-server calls that operate in your own workspace
OAuth act-on-behalf Apps you publish for other OSC users to sign in to and use their own workspaces

The OAuth flow is the right choice when you are building an application, such as one deployed on OSC itself via My Apps, where each visitor should operate with their own OSC workspace rather than yours.

Two live examples use this pattern:

  • Open Intercom Site (live demo) — lets visitors deploy and manage cloud intercom systems in their own workspace.
  • Open Media Convert (live demo) — lets visitors transcode video using OSC services provisioned in their own workspace.

The OAuth Flow End-to-End

sequenceDiagram
    participant U as User (browser)
    participant A as Your App
    participant O as app.osaas.io

    U->>A: clicks "Sign in with OSC"
    A->>A: generate PKCE pair (verifier + challenge)<br/>generate random state
    A-->>U: redirect to /api/connect/authorize<br/>(client_id, redirect_uri, code_challenge, state)
    U->>O: GET /api/connect/authorize
    O-->>U: show consent page (or login page first)
    U->>O: POST /api/connect/authorize (decision=allow)
    O-->>U: redirect to redirect_uri?code=XXX&state=YYY
    U->>A: GET /auth/callback?code=XXX&state=YYY
    A->>A: verify state matches
    A->>O: POST /api/connect/token<br/>(code, code_verifier, client_id, redirect_uri)
    O-->>A: { access_token, refresh_token, expires_in: 3600 }
    A->>A: store tokens in server-side session
    A-->>U: redirect to app (authenticated)
    A->>O: call OSC APIs with<br/>Authorization: Bearer <access_token>

Key points:

  1. PKCE (S256 method) is always required. There is no option to skip it.
  2. The state parameter is your CSRF protection. Always generate a random value and verify it on the callback.
  3. The returned access_token is a standard OSC PAT, valid for 3600 seconds.
  4. The refresh_token is valid for 7200 seconds and is used to obtain a new pair before expiry.
  5. The authorization code expires after 10 minutes. Exchange it promptly.

Registering Your OAuth App

Custom OAuth apps are available on paid plans. To register one:

  1. Sign in to the OSC dashboard.
  2. Go to My Apps in the left sidebar.
  3. Select the OAuth Apps tab.
  4. Click Create OAuth App and enter an app name.
  5. OSC generates a client_id (format: osc_<24chars>) and a client_secret (format: osc_secret_<64hex>).
  6. Copy the client_secret immediately. It is shown only once.

Your client_id identifies your application on the consent page. Your client_secret authenticates your server when exchanging codes for tokens.

Dynamic Client Registration (alternative)

If you prefer not to pre-register, your app can register itself at runtime by calling POST /api/connect/register. The registration endpoint returns a client_id and client_secret that are valid for the duration of the session. Well-known MCP clients (claude, chatgpt, mcp, cli) and UUID-format client IDs do not require registration.

Implementing the Authorize Redirect

Step 1: Generate PKCE and state

import crypto from "node:crypto";

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 };
}

function generateState(): string {
  return crypto.randomBytes(16).toString("base64url");
}

Store codeVerifier and state in your server-side session before redirecting. Never send codeVerifier to the browser.

Step 2: Build the authorization URL

GET https://app.osaas.io/api/connect/authorize

Required query parameters:

Parameter Value
response_type code
client_id your osc_... client ID
redirect_uri your callback URL (must match what you registered)
code_challenge base64url(sha256(codeVerifier))
code_challenge_method S256 (the only accepted value)
state random opaque string

Optional parameter:

Parameter Value
scope passed through but not currently evaluated

Example using URLSearchParams:

const params = new URLSearchParams({
  response_type: "code",
  client_id: clientId,
  redirect_uri: redirectUri,
  code_challenge: codeChallenge,
  code_challenge_method: "S256",
  state,
});
const authUrl = `https://app.osaas.io/api/connect/authorize?${params.toString()}`;
res.redirect(authUrl);

If the user is not signed in to OSC, the platform redirects them to the login page first and then continues the authorization flow automatically.

Step 3: Handle the callback

OSC redirects to your redirect_uri with:

  • code — a 32-character one-time code (expires in 10 minutes)
  • state — echoed back from your request

If the user denied the request, the redirect includes error=access_denied instead of code.

app.get("/auth/callback", async (req, res) => {
  const { code, state, error } = req.query;

  if (error) {
    res.status(400).send("Authorization denied");
    return;
  }

  // CSRF check
  if (state !== req.session.oauthState) {
    res.status(400).send("Invalid state parameter");
    return;
  }

  // proceed to token exchange
});

Exchanging the Code for a PAT

POST https://app.osaas.io/api/connect/token
Content-Type: application/x-www-form-urlencoded

authorization_code grant

Required form fields:

Field Value
grant_type authorization_code
code the code from the callback
code_verifier the original PKCE verifier from your session
client_id your client ID
redirect_uri same URI used in the authorize request
client_secret your client secret (required for registered custom apps)

Registered custom apps must include client_secret. UUID-format and dynamically registered clients use PKCE only and must not include client_secret.

Example:

const body = new URLSearchParams({
  grant_type: "authorization_code",
  code,
  redirect_uri: redirectUri,
  client_id: clientId,
  code_verifier: codeVerifier,
  client_secret: clientSecret, // omit for dynamic/UUID clients
});

const res = await fetch("https://app.osaas.io/api/connect/token", {
  method: "POST",
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
  body: body.toString(),
});

const tokens = await res.json();
// {
//   access_token: "...",
//   refresh_token: "...",
//   token_type: "Bearer",
//   expires_in: 3600,
//   scope: "claudeai"
// }

Store both tokens in your server-side session. Never expose them to the browser.

Calling OSC APIs as the User

The access_token is a standard OSC PAT. Pass it as a Bearer token in the Authorization header for most OSC platform APIs, or in the x-pat-jwt header for the deploy-manager and money-manager service APIs.

OSC TypeScript client SDK

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);

The Context object uses the PAT to authenticate all subsequent calls. Instances and resources created through this context appear in the authenticated user's own workspace.

Direct HTTP calls

Most platform APIs that accept the PAT use the x-pat-jwt header:

const response = await fetch("https://money.svc.prod.osaas.io/mytenantplan", {
  headers: {
    "x-pat-jwt": `Bearer ${req.session.accessToken}`,
  },
});

Deploy API calls also use x-pat-jwt:

const response = await fetch("https://deploy.svc.prod.osaas.io/terraform/mydeployments", {
  headers: {
    "x-pat-jwt": `Bearer ${req.session.accessToken}`,
  },
});

Every action taken through the user's PAT is attributed to that user's workspace. Creating a service instance costs tokens from their plan, not yours.

Token Refresh and Revocation

Refresh

The access_token expires after 3600 seconds. The refresh_token expires after 7200 seconds. Use the refresh grant to obtain a new pair before the access token expires:

POST https://app.osaas.io/api/connect/token
Content-Type: application/x-www-form-urlencoded

Form fields:

Field Value
grant_type refresh_token
refresh_token the stored refresh token

No client_id or code_verifier is needed for the refresh grant.

Response has the same shape as the initial token response: a new access_token and a new refresh_token. Replace both in your session.

async function refreshAccessToken(
  refreshToken: string,
): Promise<TokenResponse> {
  const body = new URLSearchParams({
    grant_type: "refresh_token",
    refresh_token: refreshToken,
  });

  const res = await fetch("https://app.osaas.io/api/connect/token", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: body.toString(),
  });

  if (!res.ok) throw new Error(`Token refresh failed: ${res.status}`);
  return res.json();
}

A practical approach is to subtract a small buffer (60 seconds) from expires_in when computing the expiry timestamp, so you refresh slightly before the token becomes invalid:

req.session.tokenExpiresAt = Date.now() + (tokenResponse.expires_in - 60) * 1000;

Revocation

There is no separate revocation endpoint. Tokens are automatically invalidated when they expire. To sign a user out, delete the tokens from your server-side session and redirect the user to your home page.

Example: Full Express.js Sign-In Handler

The following is a self-contained example of a minimal Express.js application that implements the complete OAuth flow. It uses dynamic client registration as a fallback when CLIENT_ID and CLIENT_SECRET environment variables are not set.

import express from "express";
import session from "express-session";
import crypto from "node:crypto";

const app = express();
app.use(session({ secret: process.env.SESSION_SECRET!, resave: false, saveUninitialized: false }));

const OSC_BASE = "https://app.osaas.io";

// --- helpers ---

function generatePKCE() {
  const codeVerifier = crypto.randomBytes(32).toString("base64url");
  const codeChallenge = crypto.createHash("sha256").update(codeVerifier).digest("base64url");
  return { codeVerifier, codeChallenge };
}

function getRedirectUri(req: express.Request): string {
  const proto = req.headers["x-forwarded-proto"] || req.protocol;
  const host = req.headers["x-forwarded-host"] || req.get("host");
  return `${proto}://${host}/auth/callback`;
}

async function registerDynamicClient(redirectUri: string) {
  const res = await fetch(`${OSC_BASE}/api/connect/register`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ redirect_uris: [redirectUri] }),
  });
  return res.json(); // { client_id, client_secret }
}

// --- routes ---

app.get("/auth/signin", async (req, res) => {
  const redirectUri = getRedirectUri(req);

  // Use pre-registered credentials when available, otherwise register dynamically
  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 registerDynamicClient(redirectUri);
    req.session.clientId = client.client_id;
    req.session.clientSecret = client.client_secret;
  }

  const { codeVerifier, codeChallenge } = generatePKCE();
  const state = crypto.randomBytes(16).toString("base64url");

  req.session.codeVerifier = codeVerifier;
  req.session.oauthState = state;

  const params = new URLSearchParams({
    response_type: "code",
    client_id: req.session.clientId!,
    redirect_uri: redirectUri,
    code_challenge: codeChallenge,
    code_challenge_method: "S256",
    state,
  });

  req.session.save(() => res.redirect(`${OSC_BASE}/api/connect/authorize?${params}`));
});

app.get("/auth/callback", async (req, res) => {
  const { code, state, error } = req.query as Record<string, string>;

  if (error) { res.status(400).send("Authorization denied"); return; }
  if (state !== req.session.oauthState) { res.status(400).send("Invalid state"); return; }

  const redirectUri = getRedirectUri(req);
  const body = new URLSearchParams({
    grant_type: "authorization_code",
    code,
    redirect_uri: redirectUri,
    client_id: req.session.clientId!,
    code_verifier: req.session.codeVerifier!,
    ...(req.session.clientSecret ? { client_secret: req.session.clientSecret } : {}),
  });

  const tokenRes = await fetch(`${OSC_BASE}/api/connect/token`, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: body.toString(),
  });
  const tokens = await tokenRes.json();

  req.session.accessToken = tokens.access_token;
  req.session.refreshToken = tokens.refresh_token;
  req.session.tokenExpiresAt = Date.now() + (tokens.expires_in - 60) * 1000;
  delete req.session.codeVerifier;
  delete req.session.oauthState;

  req.session.save(() => res.redirect("/"));
});

Example: Token Refresh Middleware

For long-running sessions (for example, when a user triggers a background job that may outlast the 3600-second token lifetime), check and refresh the access token before each API call:

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 body = new URLSearchParams({
        grant_type: "refresh_token",
        refresh_token: req.session.refreshToken!,
      });
      const res = await fetch(`${OSC_BASE}/api/connect/token`, {
        method: "POST",
        headers: { "Content-Type": "application/x-www-form-urlencoded" },
        body: body.toString(),
      });
      if (!res.ok) throw new Error(`${res.status}`);
      const tokens = await res.json();

      req.session.accessToken = tokens.access_token;
      if (tokens.refresh_token) req.session.refreshToken = tokens.refresh_token;
      if (tokens.expires_in) {
        req.session.tokenExpiresAt = Date.now() + (tokens.expires_in - 60) * 1000;
      }
      return true;
    } catch {
      delete req.session.accessToken;
      delete req.session.refreshToken;
      delete req.session.tokenExpiresAt;
      return false;
    }
  }

  return true;
}

// Example usage in an API route
app.get("/api/my-instances", async (req, res) => {
  const valid = await ensureValidToken(req);
  if (!valid) { res.status(401).json({ error: "Not authenticated" }); return; }

  // Use req.session.accessToken to call OSC APIs
  const { Context, listInstances } = await import("@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);
  res.json(instances);
});

Security Considerations

Redirect URI validation

The platform stores the redirect_uri as part of the authorization code and requires an exact match when exchanging the code for tokens. Your server should also validate that the redirect_uri it constructs matches what was registered. Use x-forwarded-proto and x-forwarded-host headers to derive the correct scheme and host when running behind a reverse proxy:

function getRedirectUri(req: express.Request): string {
  const protocol = req.headers["x-forwarded-proto"] || req.protocol;
  const host = req.headers["x-forwarded-host"] || req.get("host");
  return `${protocol}://${host}/auth/callback`;
}

State parameter

Always generate a cryptographically random state value and verify it on the callback before processing the code. This prevents cross-site request forgery attacks on the callback endpoint.

PKCE

PKCE is mandatory. The code_challenge_method must be S256. The code_verifier must never be sent to the browser or logged. Store it only in the server-side session and delete it after successful token exchange.

Token storage

Store access and refresh tokens exclusively in server-side sessions. Never:

  • Write them into HTML, JavaScript bundles, or cookie values accessible to client-side code.
  • Pass them as URL parameters.
  • Include them in AI chat prompts (they would be stored in conversation history and sent to third-party LLM providers).

Use a session store with encryption at rest (such as Redis with a signed session cookie) for production deployments.

Open redirect protection

If your app redirects users to URLs derived from parameters, validate that the destination belongs to an expected domain before redirecting. Accepting arbitrary redirect targets after authentication is an open redirect vulnerability.

HTTPS

All OSC API endpoints are HTTPS only. Ensure your own redirect_uri also uses HTTPS in production. On OSC My Apps deployments, HTTPS is provided automatically.

Live Examples

Two applications running on OSC demonstrate this pattern in production:

  • Open Intercom Site (live) — visitors sign in with their OSC account and deploy a personal cloud intercom system. All provisioned services are created in the visitor's own workspace.
  • Open Media Convert (live) — visitors sign in and submit video transcoding jobs. OSC services (MinIO, SVT Encore, Valkey) are provisioned in the visitor's own workspace and deprovisioned after the job completes.

Both apps are built with Express.js and follow the patterns described in this guide.

Getting Started

Register your OAuth app on the OSC dashboard under My Apps and OAuth Apps. The examples above assume a Node.js server with express and express-session installed. The @osaas/client-core package is available on npm for TypeScript SDK access.