EasyParcel
All articles
Platform

SSO integration

Published 9 June 2026

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_URL with the tenant's canonical domain. The examples in this document use https://online.easyparcel.co.nz for illustration only — substitute the actual production domain provided for your tenant. Always use the canonical domain — never an ephemeral *.vercel.app deployment 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

ItemDetail
OAuth clientA client_id / client_secret issued by the platform
Required scopeauth:sso (the client must be granted this scope)
User identityThe 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"]
  }'
FieldRequiredNotes
emailThe user. Auto-created on first SSO if they don't exist.
defaultAccountAccount number that becomes the user's active account. Must exist on the platform.
accountsNon-empty array of account numbers the user may access. Must exist.
roleoptionalcustomer or customer_admin (default customer_admin).
nameoptionalDisplay 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 redirectReuse or "revalidate" a token after the user has landed
Redirect the live user's browser to the SSO URLPre-fetch, preview, health-check, or server-side GET the SSO URL (that consumes the token before the user clicks)
Use the canonical BASE_URL domainUse an ephemeral *.vercel.app URL or a different host
Treat the platform session as living on BASE_URLExpect 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_URL only — 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

SymptomMeaningFix
/auth/sso shows "Token invalid or expired"Token already used, older than 60s, or unknownMint a new token (Step 2) and redirect immediately; ensure nothing pre-fetches the URL
API call returns 401OAuth access token missing/expired/invalidRe-run Step 1
400 invalid_scope on token requestClient lacks auth:ssoGrant the scope to the OAuth client
404 on login-token (Default account … not found)defaultAccount/accounts don't match platform account numbersUse exact, existing account numbers
User lands but sees "No accounts found"accounts array empty/unmatched, so no account links createdProvide 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:sso scope.
  • 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_URL domain is used everywhere.
  • No step attempts to re-validate, re-check, or reuse a login token.
  • defaultAccount and accounts use 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()/curl it server-side or follow the redirect yourself — that consumes the single-use token.
  • Keep PLATFORM_CLIENT_SECRET in env/secret storage, never client-side.

Reference: full endpoint catalogue in Platform-API-Reference.md; concrete walkthrough in Platform-API-Worked-Example.md.