← Integration guides

Node.js (Express) integration

A confidential client — your Express app holds a real client_secret. Uses express-session for server-side session state and the built-in fetch (Node 18+) for HTTP calls. Redirects to this platform's real hosted login page — see the integration guides overview — which shows every currently-real first-factor option; you never build a login form. Two different hosts are used below: accounts.onehux.com for the hosted login/logout pages a browser is redirected to, and api-accounts.onehux.com for the actual API calls your backend makes server-to-server — mixing these up is the single most common integration mistake, since the wrong host doesn't error loudly, it just 404s.

Prefer the SDK — it already implements everything below (PKCE, the redirect, callback handling, token exchange, automatic refresh-token rotation, `/userinfo`, RP-initiated logout, OIDC Back-Channel Logout, and the public application launcher), real and maintained, rather than hand-rolling the raw API calls this page walks through manually:

npm install @onehux/sso

npmjs.com/package/@onehux/sso — the manual walkthrough below is still worth reading to understand what the SDK is doing on your behalf, and remains the reference if you're integrating in a language without an official SDK.

1. Start the flow — PKCE + redirect

// routes/auth.js
import { Router } from 'express';
import crypto from 'node:crypto';

const ONEHUX_LOGIN_BASE_URL = 'https://your-org.onehux.com';     // see "Two different hosts" above
const ONEHUX_API_BASE_URL = 'https://api.your-domain.com';       // see "Two different hosts" above
const REDIRECT_URI = 'https://yourapp.example.com/auth/callback';

const router = Router();

function base64url(buf) {
	return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

router.get('/auth/login', (req, res) => {
	const codeVerifier = base64url(crypto.randomBytes(48));
	const codeChallenge = base64url(crypto.createHash('sha256').update(codeVerifier).digest());
	const state = base64url(crypto.randomBytes(16));

	// PKCE verifier + state live server-side in the session — never in a cookie the
	// browser can read, never round-tripped through the client.
	req.session.onehuxPkceVerifier = codeVerifier;
	req.session.onehuxOauthState = state;

	const params = new URLSearchParams({
		client_id: process.env.ONEHUX_CLIENT_ID,
		redirect_uri: REDIRECT_URI,
		code_challenge: codeChallenge,
		code_challenge_method: 'S256',
		scope: 'openid profile email',
		state
	});
	res.redirect(`${ONEHUX_LOGIN_BASE_URL}/login?${params.toString()}`);
});

2. Callback — verify state, exchange the code

router.get('/auth/callback', async (req, res) => {
	const { code, state } = req.query;
	const expectedState = req.session.onehuxOauthState;
	const codeVerifier = req.session.onehuxPkceVerifier;
	delete req.session.onehuxOauthState;
	delete req.session.onehuxPkceVerifier;

	if (!code || !state || state !== expectedState) {
		return res.status(400).send('Invalid or missing OAuth state.');
	}

	const tokenRes = await fetch(`${ONEHUX_API_BASE_URL}/api/v1/oauth/token/`, {
		method: 'POST',
		headers: { 'Content-Type': 'application/json' },
		body: JSON.stringify({
			grant_type: 'authorization_code',
			code,
			redirect_uri: REDIRECT_URI,
			client_id: process.env.ONEHUX_CLIENT_ID,
			client_secret: process.env.ONEHUX_CLIENT_SECRET,
			code_verifier: codeVerifier
		})
	});
	if (!tokenRes.ok) {
		return res.status(400).send('Sign-in failed.');
	}

	const tokens = await tokenRes.json(); // { access_token, id_token, refresh_token, token_type, expires_in, scope }

	// Session establishment — the access token lives server-side only (express-session's
	// own store), the same BFF discipline this platform's own dashboard follows on
	// itself. The browser only ever gets Express's session cookie, never the raw token.
	req.session.onehuxAccessToken = tokens.access_token;
	req.session.onehuxRefreshToken = tokens.refresh_token;
	res.redirect('/dashboard');
});

3. Using the token — and refreshing it

A confidential client's refresh token rotates on every use and is itself long-lived (30-day idle timeout, 30-day absolute cap — see Sessions), so a 401 from /userinfo almost always means just the 15-minute access token expired, not that the user needs to sign in again:

async function refreshTokens(req) {
	const refreshToken = req.session.onehuxRefreshToken;
	if (!refreshToken) return null;

	const res = await fetch(`${ONEHUX_API_BASE_URL}/api/v1/oauth/token/`, {
		method: 'POST',
		headers: { 'Content-Type': 'application/json' },
		body: JSON.stringify({
			grant_type: 'refresh_token',
			refresh_token: refreshToken,
			client_id: process.env.ONEHUX_CLIENT_ID,
			client_secret: process.env.ONEHUX_CLIENT_SECRET
		})
	});
	if (!res.ok) return null;

	const tokens = await res.json(); // { access_token, id_token, refresh_token, token_type, expires_in, scope }
	req.session.onehuxAccessToken = tokens.access_token;
	req.session.onehuxRefreshToken = tokens.refresh_token;
	return tokens.access_token;
}

router.get('/auth/me', async (req, res) => {
	let accessToken = req.session.onehuxAccessToken;
	if (!accessToken) return res.redirect('/auth/login');

	let userinfoRes = await fetch(`${ONEHUX_API_BASE_URL}/api/v1/oauth/userinfo/`, {
		headers: { Authorization: `Bearer ${accessToken}` }
	});
	if (userinfoRes.status === 401) {
		accessToken = await refreshTokens(req);
		if (!accessToken) {
			delete req.session.onehuxAccessToken;
			delete req.session.onehuxRefreshToken;
			return res.redirect('/auth/login');
		}
		userinfoRes = await fetch(`${ONEHUX_API_BASE_URL}/api/v1/oauth/userinfo/`, {
			headers: { Authorization: `Bearer ${accessToken}` }
		});
	}
	if (!userinfoRes.ok) {
		return res.redirect('/auth/login');
	}

	const claims = await userinfoRes.json(); // sub, name, email, picture, roles, permissions, ...
	res.json(claims);
});

export default router;

Logging out — RP-initiated, real SLO

Redirect to end the platform-wide session, not just clear your own local one — every other Application relying on that same session is signed out too:

router.get('/auth/logout', (req, res) => {
	delete req.session.onehuxAccessToken;
	delete req.session.onehuxRefreshToken;
	const params = new URLSearchParams({
		client_id: process.env.ONEHUX_CLIENT_ID,
		post_logout_redirect_uri: 'https://yourapp.example.com/logged-out'
	});
	res.redirect(`${ONEHUX_LOGIN_BASE_URL}/end-session?${params.toString()}`);
});

Register this URL too: post_logout_redirect_uri is validated against the exact same registered redirect_uris list as your login callback — if https://yourapp.example.com/logged-out isn't in that list too, /end-session rejects the request with a real 400, even though your login callback works fine.

The redirect above only notifies the app the user actually clicked "log out" in. If they instead log out of a different app, or directly at accounts.onehux.com, the shared session is still revoked immediately and correctly — and if this Application has registered a Back-Channel Logout endpoint, this platform pushes it a real, spec-compliant logout_token (server-to-server, HS256-signed with a dedicated backchannel signing secret — deliberately not your OAuth client_secret) as soon as the revocation happens, rather than waiting for your app to notice on its own:

PATCH /api/v1/applications/{id}/backchannel-logout/
{ "backchannel_logout_uri": "https://yourapp.example.com/auth/backchannel-logout" }

-> { "backchannel_logout_uri": "...", "backchannel_logout_secret": "..." }  (shown once)

Receiving and verifying that push is its own integration surface — not covered by this walkthrough. Without registering it, your app's own local session keeps showing "signed in" until its next real /userinfo call fails, bounded by the access token's 15-minute lifetime. Don't treat a locally-held session as a live signal of the IdP's true logout state unless you've wired up the push.