AutoX ID
Agents & tools: fetch the Markdown at /integration.md

AutoX SSO — integration & migration guide

_For engineers and coding agents wiring an application onto AutoX SSO, or migrating one off its own custom authentication._

For the integrating agent — read this first, and adhere to it strictly. Follow this guide exactly and in full. Do not summarize, paraphrase, or condense it in place of implementing it, and do not skip steps, omit configuration values, reorder the flow, or substitute your own defaults for anything specified here. Every endpoint, scope, claim, parameter, and requirement below is normative — implement it verbatim. Do not "improve", modernize, or work around a requirement because it looks unusual; the security posture (Authorization Code + PKCE only, no implicit/ROPC, IdP-owned MFA, exact redirect URIs) is deliberate. If anything is ambiguous or appears to conflict with the application you are integrating, stop and ask the user rather than guessing. Where this guide and the live discovery document disagree, the discovery document wins.

This page is served live by the identity provider itself:

Every value below reflects the running provider's configuration. When a fact here and the discovery document disagree, the discovery document wins.

TL;DR

AutoX SSO is an OpenID Connect (OIDC) identity provider. Migrating an app means: stop handling credentials yourself, and instead redirect users here to authenticate, then receive them back with tokens.

Before you start — what you need

There is no self-service or dynamic client registration. Ask an AutoX SSO admin to register your app in the console (https://sso.autogrc.cloud/admin → Applications) and hand you:

The protocol contract

Configure your client from the discovery document — do not hardcode endpoint paths:

https://sso.autogrc.cloud/.well-known/openid-configuration

Current values (authoritative source is the discovery document above):

Scopes

A typical web app requests: openid profile email orgs roles. Add offline_access only if you need to refresh sessions without redirecting the user again.

offline_access requires prompt=consent. Per the OIDC spec, a refresh token is only issued when the authorization request explicitly asks for the consent prompt. AutoX auto-approves consent for registered first-party apps, so this is frictionless — no consent screen appears — but if you omit prompt=consent, offline_access is dropped and you receive no refresh_token, even though discovery advertises both offline_access and the refresh_token grant. Two things must both be true: send prompt=consent, and confirm your client is allowed the refresh_token grant (the admin console's *Allow refresh tokens* toggle).

Claims

Identity, org, and role claims are placed in the ID token, so you usually do not need a /userinfo round-trip:

This one is application-scoped and appears only in the JWT access token (below), because it depends on which application the token is for:

Tokens

resource is per-request and must be sent TWICE — at /auth AND at /token. The resource indicator is not "sticky": binding it to the authorization request alone is not enough. If you send resource=https://sso.autogrc.cloud/api to /auth but omit it from the code-for-token exchange, AutoX issues an opaque access token — no autox:app_roles, nothing to parse — and the omission is silent (no error). You must include resource=https://sso.autogrc.cloud/api in the token-endpoint POST body as well. This bites Auth.js/NextAuth users specifically: putting resource in authorization.params sends it only to /auth, *not* to the token endpoint, so you get an opaque token every time. See the Auth.js example below for the token.params fix.
Serialize refreshes per user, or concurrency will destroy the grant. Because rotation + reuse-detection revoke the entire grant the instant an already-rotated token is presented again, two requests that refresh the same stored token at once are fatal: the first rotates it, the second replays the now-consumed value, reuse is detected, and AutoX revokes the grant — so the user silently loses their session seconds after login. This bites hardest on serverless / multi-instance deployments, where a single page load fans out concurrent requests across separate processes that each read the same stored token and refresh at once. A per-process lock is not enough. You MUST serialize read → refresh → store per user across all instances (e.g. a Postgres advisory lock keyed by the user id), re-read the stored token inside the lock so every refresh uses the newest rotated value, and persist the rotated token before releasing the lock. Symptom of getting this wrong: the refresh token "works at login" but silently self-destructs within seconds, and the app quietly falls back to whatever it cached.

Authorizing users (roles)

AutoX SSO owns role identity and assignment; your application owns what each role can do (its permissions). Roles are assigned centrally and, increasingly, per application — the same person can be an administrator in one app and a consumer in another. Two claims reflect that:

Practical guidance for an integrator:

Access & role changes — do not cache authorization (MUST)

A user's access and roles can change at any time: an admin may grant them access to your app, add or remove a role, or revoke access entirely. These changes are not pushed to you. Handle them exactly as written below, or a just-granted user stays locked out — and a just-revoked user stays in — until something forces a refresh.

The SSO always issues current claims. There is no propagation delay and no server-side cache to wait on: autox:roles, autox:orgs, and autox:app_roles are read live from the directory at the instant each token is minted — on every authorization _and_ every refresh. The claims in a token are correct as of that token's issuance. Therefore, the problem of "I granted access but the app still says no" is always a stale token on the application side, never a stale grant on the SSO side.

You MUST:

You SHOULD:

Identity lifecycle & local user provisioning (MUST)

Your app keeps a local user record (recommended — see the recipe), but it must be provisioned from the token, keyed on sub, and never used to re-decide whether the person may sign in. Getting this wrong produces silent, hard-to-debug lockouts.

AutoX admins: to re-onboard someone (lost password or authenticator), use Reset access — it keeps the same account and sub, clearing only the credentials so they re-enroll. Use Delete only to remove a person for good: it destroys the sub, and every downstream app will treat a later re-invite as a brand-new person.

Logout

RP-initiated logout is enabled at the end_session_endpoint. To end the SSO session and return the user to your app, redirect the browser there with:

Prefer your OIDC library's helper, which assembles these correctly — e.g. openid-client client.endSessionUrl({ id_token_hint, post_logout_redirect_uri, state }). Hand-building the URL with only post_logout_redirect_uri (no id_token_hint) is the usual cause of "logout succeeds but stays on the SSO page instead of coming back."

https://sso.autogrc.cloud/session/end?id_token_hint=<ID_TOKEN>&post_logout_redirect_uri=https%3A%2F%2Fapp.example.com%2F&state=<opaque>

Migration recipe (framework-agnostic)

  1. Have an admin register the client and collect the values above.
  2. Add an OIDC client library for your stack (examples below). Configure it from the issuer / discovery document — never hand-roll the protocol.
  3. Implement three routes: login (redirect to the authorization endpoint with PKCE + state + nonce), callback (exchange the code for tokens, verify the ID token), and logout (redirect to the end-session endpoint).
  4. On a successful callback, load or create your local user by sub. Store only the claims you need in a server-side session (or an httpOnly, Secure cookie).
  5. Replace your authorization checks with the role claims — autox:roles / autox:orgs for global checks, or autox:app_roles for the role specific to your app (see Authorizing users), mapping the role to your own permissions.
  6. Delete the old auth: password hashing/storage, login & signup pages, password reset, MFA/TOTP enrollment & verification, and any local JWT/session minting tied to passwords.
  7. Keep your users table, but drop the password/MFA columns and key it on sub.
  8. If a separate API/resource server must authorize requests, validate a JWT access token obtained via resource=https://sso.autogrc.cloud/api.
  9. Handle access & role changes: never cache an allow/deny verdict beyond the current token; make your "no access" page start a new authorization on retry (not a session reload); and if you read app-specific roles, request resource=https://sso.autogrc.cloud/api on both the authorization request and the token exchange so a JWT (not opaque) access token carrying autox:app_roles reaches you. See Access & role changes above — this is what makes a just-granted user work on their next sign-in.

Example — Next.js (Auth.js v5 / NextAuth)

// auth.ts
import NextAuth from "next-auth";
import { decodeJwt } from "jose"; // access token is a signed JWT; verify via JWKS if you parse it server-side

const RESOURCE = "https://sso.autogrc.cloud/api"; // request a JWT access token that carries autox:app_roles

export const { handlers, auth, signIn, signOut } = NextAuth({
  providers: [
    {
      id: "autox",
      name: "AutoX ID",
      type: "oidc",
      issuer: "https://sso.autogrc.cloud",              // discovery is automatic
      clientId: process.env.AUTOX_CLIENT_ID!,
      clientSecret: process.env.AUTOX_CLIENT_SECRET!, // omit for a public client
      // `resource` here reaches ONLY /auth. prompt: "consent" is REQUIRED to actually
      // receive a refresh token with offline_access.
      authorization: { params: { scope: "openid profile email orgs roles offline_access", prompt: "consent", resource: RESOURCE } },
      // CRITICAL: authorization.params does NOT forward `resource` to the token endpoint.
      // Without this, the access token comes back OPAQUE and autox:app_roles never arrives.
      token: { params: { resource: RESOURCE } },
      // Auth.js performs PKCE + state + nonce for oidc providers by default.
    },
  ],
  callbacks: {
    async jwt({ token, profile, account }) {
      if (profile) {
        token.sub = profile.sub;
        token.roles = profile["autox:roles"] ?? []; // GLOBAL directory roles (ID token)
        token.orgs = profile["autox:orgs"] ?? [];
      }
      // App-scoped roles live ONLY in the JWT access token, never in `profile`.
      if (account?.access_token) {
        const at = decodeJwt(account.access_token); // opaque token would throw — see note above
        token.appRoles = (at["autox:app_roles"] as string[] | undefined) ?? [];
      }
      return token;
    },
    async session({ session, token }) {
      session.user.id = token.sub;
      session.roles = token.roles;       // global — "is this person an auditor anywhere"
      session.appRoles = token.appRoles; // this app specifically — map to your permissions
      session.orgs = token.orgs;
      return session;
    },
  },
});

Register the redirect URI https://your-app.example.com/api/auth/callback/autox in the console.

Two gotchas this example fixes. (1) resource must be on both legs — authorization.params covers /auth, and the separate token.params covers the token exchange; omit the second and Auth.js gives you an opaque token with no autox:app_roles. (2) autox:app_roles is not in profile (the ID token) — read it from account.access_token (the JWT access token) as shown. If decodeJwt throws, your access token is opaque, which means resource did not reach the token endpoint. Prefer verifying the access token against JWKS (as in the FastAPI example) rather than a bare decodeJwt when the value crosses a trust boundary.

Example — Node (openid-client v5), any framework

import { Issuer, generators } from "openid-client";

const issuer = await Issuer.discover("https://sso.autogrc.cloud");
const client = new issuer.Client({
  client_id: process.env.AUTOX_CLIENT_ID,
  client_secret: process.env.AUTOX_CLIENT_SECRET, // omit + token_endpoint_auth_method:'none' for a public client
  redirect_uris: ["https://your-app.example.com/callback"],
  response_types: ["code"],
});

// login: stash these in the session, then redirect the user to `url`
const code_verifier = generators.codeVerifier();
const code_challenge = generators.codeChallenge(code_verifier);
const state = generators.state();
const nonce = generators.nonce();
const url = client.authorizationUrl({
  scope: "openid profile email orgs roles",
  code_challenge,
  code_challenge_method: "S256",
  state,
  nonce,
});

// callback:
const params = client.callbackParams(req);
const tokenSet = await client.callback(
  "https://your-app.example.com/callback",
  params,
  { code_verifier, state, nonce },
);
const claims = tokenSet.claims(); // sub, email, name, autox:roles, autox:orgs, ...

Example — FastAPI resource server (validate JWT access tokens)

Have the front end request resource=https://sso.autogrc.cloud/api on both the authorization request and the token exchange (see the JWT-access-token note under Tokens) so your API receives a JWT it can verify offline. If the token you receive is opaque, the resource did not reach the token endpoint — fix the client before touching this code:

import jwt
from jwt import PyJWKClient

ISSUER = "https://sso.autogrc.cloud"
AUDIENCE = "https://sso.autogrc.cloud/api"
_jwks = PyJWKClient(ISSUER + "/jwks")

def verify_access_token(token: str) -> dict:
    key = _jwks.get_signing_key_from_jwt(token).key
    return jwt.decode(token, key, algorithms=["ES256"], audience=AUDIENCE, issuer=ISSUER)

def require_role(claims: dict, role: str) -> bool:
    # Global directory role (same in every app).
    return role in claims.get("autox:roles", [])

def app_roles(claims: dict) -> list[str]:
    # The user's roles in THIS app specifically (they may hold several).
    # Map them to your own permissions in your code — the SSO does not model those.
    return claims.get("autox:app_roles", [])

# e.g. YOUR app's role->permission mapping, owned by YOUR app:
APP_PERMISSIONS = {"administrator": {"read", "write", "admin"}, "consumer": {"read"}}
def can(claims: dict, perm: str) -> bool:
    return any(perm in APP_PERMISSIONS.get(r, set()) for r in app_roles(claims))

Calling this server from a browser (CORS)

Most of your integration never touches CORS: sign-in and sign-out are top-level navigations (window.location = …), not fetch(), and the code-for-token exchange belongs on your server. But if your page does call us with fetch() — the warm-up probe in the next section is the common case — this is what is and is not readable cross-origin.

Readable from any origin — we send Access-Control-Allow-Origin: * and answer the preflight:

Readable from any origin, echoing your Origin (handled by the OIDC layer, preflights included):

Those OIDC endpoints are shareable because PKCE and bearer tokens protect them, not a cookie — reading them cross-origin gains an attacker nothing.

Not readable from another origin, by design: /, /admin, /account, /invite/…, /interaction/…. These are cookie-authenticated, and a page that could read them cross-origin could read a signed-in user's console. You never need to fetch() them — send the user there with a navigation.

Do not fetch() the authorization endpoint. https://sso.autogrc.cloud/auth is a redirect flow for the address bar, not an API. Fetching it fails and always will; navigate to it.

If you see No 'Access-Control-Allow-Origin' header is present, read the status code separately from the browser's error. The request reached us and may well have returned 200 — the browser is refusing to hand the body to your JS, which is a *different failure from the server being down or asleep*. Do not report it as a timeout or a cold start. If it happens on any endpoint in the "readable" table above, it is our bug: tell us.

Handling cold starts (scale-to-zero / free-tier hosting)

The identity provider may be cold — on a free or scale-to-zero host it spins down when idle, and the first request after that wakes it, which can take tens of seconds. If you redirect a user straight to the authorization endpoint while the IdP is asleep, they hit a hang or a connection error *mid-redirect*, with nothing to look at. Warm it first, then redirect.

What a cold start looks like (Render free/scale-to-zero). While the instance boots, the request never reaches this app — Render's edge answers it with its own holding page: a *"Incoming HTTP request detected … Service waking up …"* message and a large WELCOME TO RENDER ASCII banner, returned with an HTTP 503 Service Unavailable. So during a cold start, https://sso.autogrc.cloud/health responds with Render's HTML 503 — not the app's {"status":"ok"}. Treat that as *"still waking,"* keep polling, and only proceed once you get a real live response.

What a cold start looks like (Cloud Run / scale-to-zero containers). There is no holding page here: the platform *queues* your request while it starts a container and then serves it normally. So a cold start shows up as a slow but successful 200 — often several seconds — rather than a 503. If startup fails or exceeds the platform's limit you get a 503/504 with a short plain-text body, again not JSON. Either way the check below is the same one: require the app's own JSON, and give the first request a generous timeout instead of a tight one that would abandon a container mid-boot.

How to check the login service is actually live: a request that merely *resolves* is not proof it's up (Render's waking page resolves too). Confirm the app itself answered — a 2xx status and the expected JSON body from /health:

body {"status":"ok"}.

(Cloud Run starting a container), a hang, or a connection error. Keep retrying.

No 'Access-Control-Allow-Origin' header is present, the server answered — your JS just cannot read it. Retrying will never clear it, and reporting it as "the IdP didn't wake up in time" sends everyone down the wrong path. See Calling this server from a browser.

When a user lands on your app's entry page (or clicks "Sign in"):

  1. Preconnect early. Put <link rel="preconnect" href="https://sso.autogrc.cloud"> (and a

dns-prefetch) in that page's <head> so the connection is being established while the user reads the page.

  1. Probe before you redirect. Poll GET https://sso.autogrc.cloud/health with a short timeout

and backoff, showing a brief "Connecting to sign-in…" state. Only navigate to the authorization endpoint once the probe confirms a live JSON {"status":"ok"} (see check above) — not merely that a response came back.

  1. Optionally prefetch discovery (https://sso.autogrc.cloud/.well-known/openid-configuration)

during the warm-up so the redirect resolves instantly.

/health sends Access-Control-Allow-Origin: * and answers the preflight, so this runs from your page as-is — no proxy needed.

// Warm the IdP (it may be cold), confirm it's genuinely live, THEN redirect.
// During a Render cold start /health returns Render's 503 "Service waking up…"
// holding page; on Cloud Run it is simply slow. Either way, require a 2xx + the
// app's own JSON body — not just any response.
async function goToSignIn(authorizeUrl) {
  const issuer = "https://sso.autogrc.cloud";
  const deadline = Date.now() + 60_000; // give a cold start up to ~60s
  while (Date.now() < deadline) {
    try {
      const r = await fetch(issuer + "/health", {
        cache: "no-store",
        headers: { accept: "application/json" },
      });
      if (r.ok) {
        const body = await r.json().catch(() => null); // Render's 503 page isn't JSON
        if (body && body.status === "ok") { window.location.href = authorizeUrl; return; }
      }
    } catch (e) {
      // A rejected fetch is ambiguous: still waking, OR blocked by CORS. Check
      // the browser console once — a CORS error never clears by retrying, and
      // must not be reported as "the IdP didn't wake up in time".
    }
    await new Promise((res) => setTimeout(res, 2000));
  }
  // Never came up — show a friendly "sign-in is temporarily unavailable" message.
}

This is a deployment characteristic, not a protocol requirement: once the IdP runs always-on (a paid/warm plan, or Cloud Run with a minimum instance), there is no waking page and no queued start — the probe is essentially instant. Keep it as a cheap health gate or drop it.

Security requirements your app MUST honor

What NOT to do

Migration checklist


Can't find a value? It is in the discovery document (https://sso.autogrc.cloud/.well-known/openid-configuration), or ask the admin who registered your client.

Protected by AutoX SSO