Platform SSO — Integration Scope
Defines the supported flow and contract for signing a user into the platform from an external system using the Platform API. Share this with the SSO consumer.
Replace
BASE_URLwith the tenant's canonical domain. The examples in this document usehttps://online.easyparcel.co.nzfor illustration only — substitute the actual production domain provided for your tenant. Always use the canonical domain — never an ephemeral*.vercel.appdeployment URL.
1. Summary
The platform supports handoff SSO: your backend exchanges its OAuth credentials for a single-use, short-lived login token, then redirects the user's browser to the platform with that token. The platform consumes the token, creates a normal logged-in session (cookie), and the user lands signed in.
This is a one-shot login handoff, not a federated/session token. The login token:
- is single-use — it is consumed (invalidated) the instant the SSO URL is first loaded;
- expires after 60 seconds;
- is not a session token, refresh token, or something that can be re-validated, introspected, or reused.
After the user lands, their session is an ordinary platform session managed by a browser cookie on BASE_URL. To sign the same user in again later, mint a brand-new login token — do not reuse a previous one.
2. Prerequisites
| Item | Detail |
|---|---|
| OAuth client | A client_id / client_secret issued by the platform |
| Required scope | auth:sso (the client must be granted this scope) |
| User identity | The user's email (the SSO subject) |
| Account(s) | The customer account number(s) the user should have access to, and which is their default |
3. The supported flow (3 steps)
Step 1 — Get an OAuth access token
POST {BASE_URL}/api/v1/oauth/token (client-credentials grant).
curl -s -X POST "$BASE_URL/api/v1/oauth/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=$CLIENT_ID" \
-d "client_secret=$CLIENT_SECRET"
200 OK
{ "access_token": "eyJhbG…", "token_type": "Bearer", "expires_in": 3600, "scope": "… auth:sso" }
This access token is reusable for ~1 hour across many API calls (cache it; refresh ~1 min before expiry).
Step 2 — Mint a single-use login token (server-to-server)
POST {BASE_URL}/api/v1/auth/login-token with the OAuth bearer.
curl -s -X POST "$BASE_URL/api/v1/auth/login-token" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"email": "jane.smith@acme.example",
"name": "Jane Smith",
"role": "customer_admin",
"defaultAccount": "ACME-001",
"accounts": ["ACME-001"]
}'
| Field | Required | Notes |
|---|---|---|
email | ✅ | The user. Auto-created on first SSO if they don't exist. |
defaultAccount | ✅ | Account number that becomes the user's active account. Must exist on the platform. |
accounts | ✅ | Non-empty array of account numbers the user may access. Must exist. |
role | optional | customer or customer_admin (default customer_admin). |
name | optional | Display name (defaults to the email). |
201 Created
{ "token": "771d86b0-bd86-4478-874a-4b016ea13658" }
name and role are refreshed on every call, and account links are ensured each time.
Step 3 — Redirect the user's browser (within 60 seconds)
Send the user to:
{BASE_URL}/auth/sso?token=<token from step 2>
The platform validates + consumes the token, establishes the session cookie, and lands the user signed in with defaultAccount active.
4. Token contract (read this — it's the cause of most failures)
- Single-use. The token is marked used the moment
/auth/sso?token=…is first loaded. A second load of the same token returns "Token invalid or expired." - 60-second TTL. If more than 60 seconds pass between Step 2 and the browser loading the SSO URL, the token is expired → same error.
- No revalidation / introspection. There is no endpoint to "re-validate", "check", or "refresh" a login token, and the token is not a session reference. Do not build a step that re-checks or re-submits a previously issued token.
- Mint fresh every time. For each sign-in or re-entry into the platform, perform Steps 2–3 again to get a new token. Tokens are cheap to mint.
Do / Don't
| ✅ Do | ❌ Don't |
|---|---|
| Mint a new token immediately before each redirect | Reuse or "revalidate" a token after the user has landed |
| Redirect the live user's browser to the SSO URL | Pre-fetch, preview, health-check, or server-side GET the SSO URL (that consumes the token before the user clicks) |
Use the canonical BASE_URL domain | Use an ephemeral *.vercel.app URL or a different host |
Treat the platform session as living on BASE_URL | Expect the token to carry session state back to your domain |
⚠️ Common pitfall: putting the
/auth/sso?token=…URL anywhere it gets auto-fetched (email link scanners, chat link previews, browser prefetch, uptime checks, or a server-side redirect that follows the URL) will consume the single-use token before the user clicks it, so the real click then fails with "invalid token." The SSO URL must be opened exactly once, by the actual user's browser.
5. After the user is signed in
- The session is a standard cookie-based session on
BASE_URL. The user stays logged in per the platform's normal session lifetime (subject to inactivity timeout). - The session cookie is scoped to
BASE_URLonly — it is not visible to your domain. Your application cannot read or "validate" the platform session from your side. - Returning a user to the platform later = repeat Steps 2–3 (new token). There is no "is this user still signed in?" check exposed to integrators.
6. Error reference
| Symptom | Meaning | Fix |
|---|---|---|
/auth/sso shows "Token invalid or expired" | Token already used, older than 60s, or unknown | Mint a new token (Step 2) and redirect immediately; ensure nothing pre-fetches the URL |
API call returns 401 | OAuth access token missing/expired/invalid | Re-run Step 1 |
400 invalid_scope on token request | Client lacks auth:sso | Grant the scope to the OAuth client |
404 on login-token (Default account … not found) | defaultAccount/accounts don't match platform account numbers | Use exact, existing account numbers |
| User lands but sees "No accounts found" | accounts array empty/unmatched, so no account links created | Provide valid account numbers in accounts |
7. Sequence
Your backend Platform API User's browser Platform web app
| | | |
|--POST /oauth/token--->| | |
|<------ access_token --| | |
| | | |
|--POST /auth/login-token (Bearer)-----------------> | |
|<------------------------------- { token } -------- | |
| | |
|---- redirect user to BASE_URL/auth/sso?token=… ----> | |
| |--- GET /auth/sso?token ->|
| | (token consumed, |
| | session cookie set) |
| |<--- signed in, lands ----|
| | at dashboard |
(to re-enter later: mint a NEW token and redirect again)
8. Integrator checklist
- OAuth client has the
auth:ssoscope. - Access token is cached and refreshed (Step 1), separate from login tokens.
- A new login token is minted for every sign-in/re-entry (Step 2).
- The browser is redirected to
BASE_URL/auth/sso?token=…within 60 seconds, and the URL is not fetched/previewed by anything but the user. - The canonical
BASE_URLdomain is used everywhere. - No step attempts to re-validate, re-check, or reuse a login token.
-
defaultAccountandaccountsuse real platform account numbers.
9. Reference implementation (Node / TypeScript)
Drop-in, framework-light. It caches the OAuth access token (~1 h), mints a fresh single-use login token per sign-in, and hands the URL to the user's browser to load once.
// platform-sso.ts
//
// Correct mint-then-redirect SSO handoff for the Platform API.
// - Caches the OAuth access token (~1h) and reuses it across requests.
// - Mints a FRESH single-use login token for EVERY sign-in, then 302-redirects
// the live user's browser. Never pre-fetch / server-side GET the SSO URL,
// and never reuse or "revalidate" a login token.
const BASE_URL = process.env.PLATFORM_BASE_URL!; // canonical domain, e.g. https://online.easyparcel.co.nz (example only)
const CLIENT_ID = process.env.PLATFORM_CLIENT_ID!;
const CLIENT_SECRET = process.env.PLATFORM_CLIENT_SECRET!;
// ---- OAuth access-token cache (reused for ~1 hour, separate from login tokens) ----
let accessToken: string | null = null;
let accessTokenExpiry = 0; // epoch ms
async function getAccessToken(): Promise<string> {
if (accessToken && Date.now() < accessTokenExpiry - 60_000) return accessToken;
const body = new URLSearchParams({
grant_type: "client_credentials",
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
scope: "auth:sso",
});
const res = await fetch(`${BASE_URL}/api/v1/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
});
if (!res.ok) throw new Error(`OAuth token request failed: ${res.status} ${await res.text()}`);
const data = (await res.json()) as { access_token: string; expires_in: number };
accessToken = data.access_token;
accessTokenExpiry = Date.now() + data.expires_in * 1000;
return accessToken;
}
export interface SsoUser {
email: string; // SSO subject
defaultAccount: string; // account NUMBER, e.g. "ACME-001"
accounts: string[]; // account NUMBERS the user may access (non-empty)
name?: string; // display name (defaults to email)
role?: "customer" | "customer_admin"; // defaults to "customer_admin"
}
/**
* Mint a FRESH single-use login token and return the SSO redirect URL.
* The URL is valid for ONE load within 60 seconds. Redirect the user's browser
* to it immediately; do not fetch, preview, log, or store it for reuse.
*/
export async function createSsoRedirectUrl(user: SsoUser): Promise<string> {
const token = await getAccessToken();
const res = await fetch(`${BASE_URL}/api/v1/auth/login-token`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({
email: user.email,
name: user.name,
role: user.role ?? "customer_admin",
defaultAccount: user.defaultAccount,
accounts: user.accounts,
}),
});
// One retry if the cached access token was rejected (e.g. expired at the edge)
if (res.status === 401) {
accessToken = null;
return createSsoRedirectUrl(user);
}
if (!res.ok) throw new Error(`login-token failed: ${res.status} ${await res.text()}`);
const { token: loginToken } = (await res.json()) as { token: string };
return `${BASE_URL}/auth/sso?token=${encodeURIComponent(loginToken)}`;
}
Express route — mint per request, 302-redirect the browser:
import express from "express";
import { createSsoRedirectUrl } from "./platform-sso";
const app = express();
// The user is already authenticated on YOUR side (req.user). Adapt to your auth.
app.get("/go-to-platform", async (req, res) => {
try {
const url = await createSsoRedirectUrl({
email: req.user.email,
name: req.user.fullName,
role: "customer_admin",
defaultAccount: req.user.defaultAccountNumber,
accounts: req.user.accountNumbers,
});
// The USER'S browser performs the one-shot GET that consumes the token:
res.redirect(302, url);
} catch (err) {
console.error("SSO handoff failed:", err);
res.status(502).send("Could not start platform sign-in. Please try again.");
}
});
Critical usage rules for this snippet
- Call
createSsoRedirectUrl()on each sign-in / re-entry — never cache or reuse the returned URL or token. - Hand the URL to the browser (HTTP 302). Do not
fetch()/curlit server-side or follow the redirect yourself — that consumes the single-use token. - Keep
PLATFORM_CLIENT_SECRETin env/secret storage, never client-side.
Reference: full endpoint catalogue in Platform-API-Reference.md; concrete walkthrough in Platform-API-Worked-Example.md.