← Integration guides

Django integration

A confidential client — your Django app holds a real client_secret (from registering your Application, see Organizations & Applications). 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:

pip install onehux-sso

pypi.org/project/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

# yourapp/views.py
import base64
import hashlib
import secrets
from urllib.parse import urlencode

from django.conf import settings
from django.http import HttpResponseRedirect

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


def _b64url(data: bytes) -> str:
    return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")


def login(request):
    code_verifier = _b64url(secrets.token_bytes(48))
    code_challenge = _b64url(hashlib.sha256(code_verifier.encode()).digest())
    state = _b64url(secrets.token_bytes(16))

    # PKCE verifier + state live server-side in the session — never in a cookie the
    # browser can read, never round-tripped through the client.
    request.session["onehux_pkce_verifier"] = code_verifier
    request.session["onehux_oauth_state"] = state

    params = {
        "client_id": settings.ONEHUX_CLIENT_ID,
        "redirect_uri": REDIRECT_URI,
        "code_challenge": code_challenge,
        "code_challenge_method": "S256",
        "scope": "openid profile email",
        "state": state,
    }
    return HttpResponseRedirect(f"{ONEHUX_LOGIN_BASE_URL}/login?{urlencode(params)}")

2. Callback — verify state, exchange the code

# yourapp/views.py (continued)
import requests
from django.http import HttpResponseBadRequest, HttpResponseRedirect


def callback(request):
    code = request.GET.get("code")
    state = request.GET.get("state")
    expected_state = request.session.pop("onehux_oauth_state", None)
    code_verifier = request.session.pop("onehux_pkce_verifier", None)

    if not code or not state or state != expected_state:
        return HttpResponseBadRequest("Invalid or missing OAuth state.")

    token_res = requests.post(
        f"{ONEHUX_API_BASE_URL}/api/v1/oauth/token/",
        json={
            "grant_type": "authorization_code",
            "code": code,
            "redirect_uri": REDIRECT_URI,
            "client_id": settings.ONEHUX_CLIENT_ID,
            "client_secret": settings.ONEHUX_CLIENT_SECRET,
            "code_verifier": code_verifier,
        },
        timeout=10,
    )
    if not token_res.ok:
        return HttpResponseBadRequest("Sign-in failed.")

    tokens = token_res.json()  # {access_token, id_token, refresh_token, token_type, expires_in, scope}

    # Session establishment — the access token lives server-side only (Django's own
    # session store), the same BFF discipline this platform's own dashboard follows on
    # itself. The browser only ever gets Django's session cookie, never the raw token.
    request.session["onehux_access_token"] = tokens["access_token"]
    request.session["onehux_refresh_token"] = tokens["refresh_token"]
    return HttpResponseRedirect("/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:

def _refresh_tokens(request) -> str | None:
    """Rotates the stored refresh token for a new access/refresh pair. Returns the new
    access token, or None if there's nothing to refresh with or the refresh itself fails
    (expired, already used, or the whole family was revoked — see Sessions)."""
    refresh_token = request.session.get("onehux_refresh_token")
    if not refresh_token:
        return None

    res = requests.post(
        f"{ONEHUX_API_BASE_URL}/api/v1/oauth/token/",
        json={
            "grant_type": "refresh_token",
            "refresh_token": refresh_token,
            "client_id": settings.ONEHUX_CLIENT_ID,
            "client_secret": settings.ONEHUX_CLIENT_SECRET,
        },
        timeout=10,
    )
    if not res.ok:
        return None

    tokens = res.json()  # {access_token, id_token, refresh_token, token_type, expires_in, scope}
    request.session["onehux_access_token"] = tokens["access_token"]
    request.session["onehux_refresh_token"] = tokens["refresh_token"]
    return tokens["access_token"]


def me(request):
    access_token = request.session.get("onehux_access_token")
    if not access_token:
        return HttpResponseRedirect("/auth/login")

    res = requests.get(
        f"{ONEHUX_API_BASE_URL}/api/v1/oauth/userinfo/",
        headers={"Authorization": f"Bearer {access_token}"},
        timeout=10,
    )
    if res.status_code == 401:
        access_token = _refresh_tokens(request)
        if access_token is None:
            request.session.pop("onehux_access_token", None)
            request.session.pop("onehux_refresh_token", None)
            return HttpResponseRedirect("/auth/login")
        res = requests.get(
            f"{ONEHUX_API_BASE_URL}/api/v1/oauth/userinfo/",
            headers={"Authorization": f"Bearer {access_token}"},
            timeout=10,
        )
    if not res.ok:
        return HttpResponseRedirect("/auth/login")

    claims = res.json()  # sub, name, email, picture, roles, permissions, ...
    return JsonResponse(claims)

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:

def logout(request):
    request.session.pop("onehux_access_token", None)
    request.session.pop("onehux_refresh_token", None)
    params = urlencode({
        "client_id": settings.ONEHUX_CLIENT_ID,
        "post_logout_redirect_uri": "https://yourapp.example.com/logged-out",
    })
    return HttpResponseRedirect(f"{ONEHUX_LOGIN_BASE_URL}/end-session?{params}")

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.