/integration.md_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:
https://sso.autogrc.cloud/integration.mdhttps://sso.autogrc.cloud/integrationEvery value below reflects the running provider's configuration. When a fact here and the discovery document disagree, the discovery document wins.
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.
response_type=code only. No implicit, no hybrid, no password/ROPC grant.sub claim. Passkeys and MFA are owned entirely by the IdP; your app never sees them.autox:roles + autox:orgs (global), or — for the role scoped to *your* app specifically — autox:app_roles (see Authorizing users below). The SSO issues role identity; your app maps a role to its own permissions.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:
https://sso.autogrc.cloudtoken_endpoint_auth_method: none).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):
https://sso.autogrc.cloudhttps://sso.autogrc.cloud/authhttps://sso.autogrc.cloud/tokenhttps://sso.autogrc.cloud/mehttps://sso.autogrc.cloud/jwkshttps://sso.autogrc.cloud/session/endopenid (required) — yields subprofile — name, preferred_usernameemail — email, email_verifiedorgs — autox:org_type, autox:is_ey_employee, autox:orgsroles — autox:rolesoffline_access — issues a (rotating) refresh token. Only granted when the authorization request also sends prompt=consent (see the note below); without it the scope is silently dropped and no refresh token is minted.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_accessrequiresprompt=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 omitprompt=consent,offline_accessis dropped and you receive norefresh_token, even though discovery advertises bothoffline_accessand therefresh_tokengrant. Two things must both be true: sendprompt=consent, and confirm your client is allowed therefresh_tokengrant (the admin console's *Allow refresh tokens* toggle).
Identity, org, and role claims are placed in the ID token, so you usually do not need a /userinfo round-trip:
sub — stable, unique user id. Use this as the foreign key to your local user record. It never changes and is never reused.email, email_verified, name, preferred_usernameautox:is_ey_employee — booleanautox:org_type — "EY" or "client"autox:orgs — array of { id, name, type, team, role }autox:roles — de-duplicated array of the user's directory role strings across all membershipsThis one is application-scoped and appears only in the JWT access token (below), because it depends on which application the token is for:
autox:app_roles — an array of the roles this user holds specifically in your application (they may hold several — e.g. reviewer + auditor — and a different set in another app). Present only if an admin assigned them roles for your client. Prefer this over autox:roles when you want *their roles in your app*. Your app decides what those roles can do — the SSO issues role identity, not permissions.iss, aud (= your client_id), exp, and nonce./me, or hand it to services that introspect it. Default lifetime ~10 minutes (admin-configurable).resource=https://sso.autogrc.cloud/api on both the authorization request _and_ the token exchange (see the note below — sending it only at /auth is the most common way to get an opaque token by mistake). You then receive a JWT access token (ES256, aud=https://sso.autogrc.cloud/api) carrying autox:is_ey_employee, autox:org_type, autox:roles, and — when the user has an app assignment — the application-scoped autox:app_roles. Validate it via JWKS. This is per-request and strictly opt-in.resourceis per-request and must be sent TWICE — at/authAND at/token. The resource indicator is not "sticky": binding it to the authorization request alone is not enough. If you sendresource=https://sso.autogrc.cloud/apito/authbut omit it from the code-for-token exchange, AutoX issues an opaque access token — noautox:app_roles, nothing to parse — and the omission is silent (no error). You must includeresource=https://sso.autogrc.cloud/apiin the token-endpoint POST body as well. This bites Auth.js/NextAuth users specifically: puttingresourceinauthorization.paramssends it only to/auth, *not* to the token endpoint, so you get an opaque token every time. See the Auth.js example below for thetoken.paramsfix.
offline_access plus prompt=consent on the authorization request (and the client's refresh_token grant) — see Scopes above. It rotates on every use, with reuse detection — always persist the newest one and discard the previous. Replaying an old (e.g. stolen) refresh token revokes the whole chain.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.
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:
autox:roles — the user's directory roles across all memberships. Global: identical in every app's token. Fine for coarse checks ("is this person an auditor anywhere").autox:app_roles — an array of the roles the user holds in your application specifically (they may hold several). Present only in the JWT access token (it depends on which app the token is for) and only when an admin assigned them roles for your client. Prefer this whenever you mean "their roles in my app". Map them to your own permissions/capabilities in your code.Practical guidance for an integrator:
autox:roles as their standing in your app. For app-specific decisions read autox:app_roles from the JWT access token (request resource=https://sso.autogrc.cloud/api on both the authorization request and the token exchange — see below). Reading it from profile / the ID token will never work: app_roles is not there.autox:roles only; the app-scoped autox:app_roles rides the JWT access token because that token is minted for a specific audience.autox:app_roles claim at all — the claim appears only when the user holds ≥1 role in your app. So if your code checks app_roles, make sure the user is assigned at least one role; an "access, but no role" assignment is invisible in the token and looks identical to "not assigned."app_roles.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:
/auth) with fresh PKCE + state + nonce. The user almost always still has a live SSO session, so this is a fast round-trip that usually requires no re-entry of credentials, and the resulting token reflects the change immediately.autox:app_roles (the roles the user holds in your application) appears only in the JWT access token — you must request resource=https://sso.autogrc.cloud/api on both the authorization request and the token exchange to receive it. If you authorize on app-specific roles but never request that resource (or request it only at /auth, so the access token comes back opaque), a per-app grant will never reach you, no matter how many times the user re-authenticates. For app-specific decisions: request the API resource on both legs, read autox:app_roles from the access token (not the ID token / profile), and confirm the access token is actually a JWT before trusting it. autox:roles in the ID token is global and is not a substitute.You SHOULD:
offline_access, a refreshed access token also carries current claims, so a granted or revoked role takes effect on the next refresh without a full redirect — provided you re-read the claims each time and do not cache roles for longer than the token.invalid_grant. Handle a failed refresh like an expired session — send the user back through /auth, where they are cleanly refused if access is gone — rather than leaving them in a half-broken state. This bounds how long a revoked user keeps working to the access-token lifetime (~10 min), since they can no longer renew.{ "active": false }.prompt=login or max_age=0 to the authorization request. You still receive current claims.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.
sub is the identity — key on it, and only it. Look the user up by sub on every sign-in; if found, refresh mutable fields (email, name) and continue. A sub is stable for the life of an AutoX account and is never reused for anyone else. Do not key users on email.email_verified === true. Use it only to link a _pre-existing, still-unlinked_ local row (autox_sub IS NULL) to the sub the first time that person signs in with SSO. After migration, an unknown sub should create a fresh row — not hunt for an email to adopt. (AutoX sends email_verified as a strict boolean; request the email scope to receive it.)sub changes when an AutoX account is deleted and re-created — but NOT when it is reset. Deleting removes the account; a later re-invite is a _new_ account with a _new_ sub (the old one is retired forever). So a returning person can arrive with the same verified email but a new sub. Never turn that into a hard "not permitted" denial. In a single-trusted-IdP deployment you may safely re-link the existing row to the new sub when email_verified is true — this preserves the row's data — provided you (a) log every re-link (old → new sub, email, time) and (b) accept the one thing it trusts: _AutoX never reassigns an email to a different human._ AutoX enforces one-email-per-live-account, so this holds under normal operation; revisit it only if you add a second IdP or start hanging sensitive data off the row.AutoX admins: to re-onboard someone (lost password or authenticator), use Reset access — it keeps the same account andsub, clearing only the credentials so they re-enroll. Use Delete only to remove a person for good: it destroys thesub, and every downstream app will treat a later re-invite as a brand-new person.
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:
id_token_hint — required to be redirected back. Pass the ID token you received at login (keep it in the user's session for exactly this purpose; an expired one is still accepted here, it only identifies the client + subject). Without it the SSO cannot identify your client, cannot validate your post_logout_redirect_uri, and will not honor it — the user is logged out but lands on the SSO's own "signed out" page instead of returning to your app. Registering the URI is not sufficient on its own; the hint is what lets the SSO trust it.post_logout_redirect_uri — must be registered for your client in the console and match the request byte-for-byte, including the trailing slash (https://app.example.com/ ≠ https://app.example.com).state (optional, recommended) — an opaque value echoed back so you can confirm the round-trip.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>
sub. Store only the claims you need in a server-side session (or an httpOnly, Secure cookie).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.sub.resource=https://sso.autogrc.cloud/api.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.// 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)resourcemust be on both legs —authorization.paramscovers/auth, and the separatetoken.paramscovers the token exchange; omit the second and Auth.js gives you an opaque token with noautox:app_roles. (2)autox:app_rolesis not inprofile(the ID token) — read it fromaccount.access_token(the JWT access token) as shown. IfdecodeJwtthrows, your access token is opaque, which meansresourcedid not reach the token endpoint. Prefer verifying the access token against JWKS (as in the FastAPI example) rather than a baredecodeJwtwhen the value crosses a trust boundary.
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, ...
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))
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:
https://sso.autogrc.cloud/health — the liveness probe.https://sso.autogrc.cloud/integration, https://sso.autogrc.cloud/integration.md, https://sso.autogrc.cloud/llms.txt — this guide.Readable from any origin, echoing your Origin (handled by the OIDC layer, preflights included):
https://sso.autogrc.cloud/.well-known/openid-configurationhttps://sso.autogrc.cloud/jwkshttps://sso.autogrc.cloud/token and https://sso.autogrc.cloud/meThose 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 notfetch()the authorization endpoint.https://sso.autogrc.cloud/authis 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.
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:
GET https://sso.autogrc.cloud/health → 200 with content-type: application/json andbody {"status":"ok"}.
503 (Render's HTML "Service waking up…" page), a slow answer(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"):
<link rel="preconnect" href="https://sso.autogrc.cloud"> (and adns-prefetch) in that page's <head> so the connection is being established while the user reads the page.
GET https://sso.autogrc.cloud/health with a short timeoutand 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.
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.
iss, aud (= your client_id), exp, and nonce. A reputable library does this for you — do not hand-roll token validation.aud.kid.resource=https://sso.autogrc.cloud/api on both the authorization request and the token exchange. Sending it only at /auth yields an opaque token, silently.autox:app_roles in profile / the ID token — it is never there. It rides the JWT access token only.autox:roles or autox:app_roles into your database as the source of truth. Read them from the token each session — roles and per-app assignments are managed centrally and change (including renames) without touching your app.autox:roles — it is global. Use autox:app_roles for "which role do they have *in my app*", then map it to your app's permissions.https://sso.autogrc.cloud/admin; you have client_id (+ secret if confidential) and the redirect_urihttps://sso.autogrc.cloudsubautox:roles / autox:orgs, or autox:app_roles (read from the JWT access token, not profile/ID token) for app-scoped access, mapped to your app's own permissionsautox:app_roles: resource=https://sso.autogrc.cloud/api is sent on both the authorization request and the token exchange, and you've confirmed the access token comes back as a JWT (not opaque)end_session_endpointresource=https://sso.autogrc.cloud/api JWTs via JWKSCan'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.