Metadata-Version: 2.4
Name: onehux-sso
Version: 0.2.0
Summary: Django SDK for OneHux Accounts SSO (Authorization Code + PKCE, real hosted login page, RP-initiated logout).
License: Apache-2.0
Project-URL: Homepage, https://accounts.onehux.com
Project-URL: Documentation, https://accounts.onehux.com/dashboard/docs/integrate/backend/django
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: Django>=5.2
Requires-Dist: requests>=2.31
Requires-Dist: PyJWT>=2.8
Provides-Extra: test
Requires-Dist: pytest>=8.0; extra == "test"
Requires-Dist: pytest-django>=4.8; extra == "test"
Dynamic: license-file

# onehux-sso

A real, installable Django app wrapping OneHux Accounts' Authorization Code + PKCE flow
against its real hosted login page — formalizing what
[the Django integration guide](https://accounts.onehux.com/dashboard/docs/integrate/backend/django)
otherwise only shows as copy-paste example code.

## Install

```bash
pip install onehux-sso
```

[pypi.org/project/onehux-sso](https://pypi.org/project/onehux-sso/)

## Two hosts — don't mix them up

`accounts.onehux.com` serves the hosted login/logout pages a browser is redirected to.
`api-accounts.onehux.com` serves the actual OAuth API your backend calls server-to-server.
This package keeps them as two separate settings (`LOGIN_BASE_URL` / `API_BASE_URL`) precisely
because collapsing them into one host was a real, confirmed bug in the original integration
guides (see the backend repo's `README.md`, ADR-070) — the wrong host doesn't error loudly, it
silently 404s.

**If your Organization has a live custom domain** (Dashboard → Settings → Branding, see the
backend repo's `README.md` ADR-027), set `LOGIN_BASE_URL` to that domain instead — it's what
your end users' browsers actually land on, so it should match whatever you've branded. Never
override `API_BASE_URL`: it has no per-Organization customization and never needs any — every
call there is server-to-server via your `client_id`/`client_secret`, never seen by an end user.

## Setup

1. Register a real confidential-client `Application` in your OneHux Accounts Organization
   (Dashboard → Applications), with a `redirect_uri` pointing at wherever you mount this
   package's `callback/` URL, **and** your `post_logout_redirect_uri` registered in that same
   list — OneHux Accounts validates both against the one `redirect_uris` list, not two
   separate ones.

2. Add to `INSTALLED_APPS`:

   ```python
   INSTALLED_APPS = [
       ...,
       "onehux_sso",
   ]
   ```

3. Add the settings block:

   ```python
   ONEHUX_SSO = {
       "CLIENT_ID": "onehux_client_...",
       "CLIENT_SECRET": "onehux_secret_...",
       "REDIRECT_URI": "https://yourapp.example.com/auth/callback/",
       "POST_LOGOUT_REDIRECT_URI": "https://yourapp.example.com/auth/logged-out/",
       # LOGIN_BASE_URL / API_BASE_URL below are optional — they default to this platform's
       # own shared production hosts (see conf.py), the same for every integrator unless your
       # Organization has a custom domain (Dashboard → Settings → Branding). Set them only to
       # override that, e.g.:
       # "LOGIN_BASE_URL": "https://your-org.onehux.com",
       # "API_BASE_URL": "https://api.your-domain.com",
       "SCOPE": "openid profile email",
       "LOGIN_SUCCESS_REDIRECT": "/",
       "LOGOUT_SUCCESS_REDIRECT": "/",
       "SESSION_ACCESS_TOKEN_KEY": "onehux_access_token",
   }
   ```

4. Wire the URLs:

   ```python
   # yourproject/urls.py
   from django.urls import include, path

   urlpatterns = [
       ...,
       path("auth/", include("onehux_sso.urls")),
   ]
   ```

This gives you four real, working endpoints: `/auth/login/`, `/auth/callback/`,
`/auth/logout/`, and `/auth/userinfo/` (a ready-to-use JSON endpoint your own frontend can call
with `credentials: 'include'`, matching the BFF pattern — your frontend never talks to OneHux
directly).

## Using the client directly

If you'd rather wire your own views instead of using the ones above:

```python
from onehux_sso import OneHuxClient, TokenExpiredError

client = OneHuxClient.from_settings()

pending = client.start_authorization()
# stash pending.state / pending.code_verifier in request.session, then:
# return HttpResponseRedirect(pending.authorization_url)

tokens = client.exchange_code(
    code=request.GET["code"],
    state=request.GET["state"],
    expected_state=request.session["onehux_sso_state"],
    code_verifier=request.session["onehux_sso_pkce_verifier"],
)
# tokens.refresh_token: persist it server-side alongside tokens.access_token if you're not
# using UserInfoView / onehux_login_required (both already do this for you) — see "Refresh
# tokens" below.

try:
    claims = client.get_userinfo(access_token=tokens.access_token)
except TokenExpiredError:
    # get_userinfo() never retries itself (it's a pure API call, no session concept) — a
    # caller using OneHuxClient directly owns this retry, same as UserInfoView and
    # onehux_login_required do internally. See "Refresh tokens" below.
    refreshed = client.refresh_access_token(refresh_token=request.session["onehux_sso_refresh_token"])
    request.session["onehux_sso_refresh_token"] = refreshed.refresh_token  # rotated — persist the new one
    claims = client.get_userinfo(access_token=refreshed.access_token)

logout_url = client.build_logout_url()
```

## Public application launcher

`GET /api/v1/organizations/{org_slug}/public-applications/` is a real, public, unauthenticated
platform endpoint — no `client_id`/`client_secret` involved, usable for any Organization by its
own slug, not just your own configured one. It returns only `name`/`logo_url`/`home_url` for
Applications that Organization has opted into public listing — a pure "what can I launch" list,
never a way to start a sign-in flow.

```python
apps = client.get_public_applications(org_slug="onehux")
# [PublicApplication(name="ODS", logo_url="https://...", home_url="https://...")]
```

Rendering is entirely up to you — this package ships the data method only, no template or
component. A plain, unstyled illustration (adapt this to your own design, don't copy it as-is):

```html
{% for app in public_applications %}
  <a href="{{ app.home_url }}">
    <img src="{{ app.logo_url }}" alt="{{ app.name }}">
    {{ app.name }}
  </a>
{% endfor %}
```

## Logging out — what actually happens, and how to hear about it immediately

Two distinct logout paths reach the platform's identical underlying session-revocation call
(`POST /api/v1/sessions/me/logout/`), and OneHux Accounts genuinely, immediately revokes the
platform-wide session either way — this was traced directly against the backend, not assumed.
What differs is how *this app* finds out:

- **RP-initiated logout** — the user clicks "log out" inside this app itself
  (`client.build_logout_url()` / `/auth/logout/`). This app already knows: it's the one that
  cleared `request.session[SESSION_ACCESS_TOKEN_KEY]` and drove the redirect. Nothing further
  to do.
- **IdP-initiated logout** — the user logs out of a *different* app, or directly at
  `accounts.onehux.com/dashboard`. The platform-wide session is revoked immediately and
  correctly, exactly the same as the RP-initiated case — but this app only finds out if it's
  listening for it.

**OneHux Accounts implements real OIDC Back-Channel Logout** (spec:
[openid-connect-backchannel-1_0](https://openid.net/specs/openid-connect-backchannel-1_0.html))
to close that gap: `BackchannelLogoutView` receives a signed `logout_token` POST the instant any
session tied to this app is revoked, anywhere, and clears the matching local Django session
immediately — not on the next stale `/userinfo` call.

**To turn this on:**

1. Mount the package's URLs as shown in Setup above — `BackchannelLogoutView` is already
   included at `/auth/backchannel-logout/` (adjust for whatever prefix you mounted at).
2. Register that exact URL with OneHux:
   ```
   PATCH /api/v1/applications/{id}/backchannel-logout/
   { "backchannel_logout_uri": "https://yourapp.example.com/auth/backchannel-logout/" }
   ```
   The response includes `backchannel_logout_secret` **exactly once** — this is a dedicated
   signing secret, deliberately **not** your `CLIENT_SECRET` (the backend stores that only as a
   one-way hash and can never read it back to sign anything with it).
3. Set `ONEHUX_SSO['BACKCHANNEL_LOGOUT_SIGNING_SECRET']` to that value.

Without steps 1–3, IdP-initiated logout is still real and immediate at the platform level — this
app just won't hear about it until its own next `/userinfo` call fails with `TokenExpiredError`,
bounded by the access token's 15-minute lifetime. With them wired up, both logout paths are
functionally immediate from this app's point of view too.

## Refresh tokens

OneHux Accounts access tokens are a 15-minute, single-issue lifetime — that hasn't changed.
What has: every real login now also issues a **refresh token** (backend repo README.md
ADR-081, RFC 6749 §6 / RFC 9700 §4.14.2 rotation with reuse detection), which this package
uses to renew an expired access token without a full re-login.

`UserInfoView` (`GET /auth/userinfo/`) and the `onehux_login_required` decorator /
`OneHuxLoginRequiredMixin` (see "Protecting your own views" below) do this automatically: an
expired access token triggers exactly one silent `client.refresh_access_token()` call using
the session's stored refresh token, and the caller only ever sees `TokenExpiredError` if that
refresh also fails. The new access/refresh token pair is persisted back into the session,
replacing the old one — **a refresh token is single-use and rotates on every real use**, the
old value stops working the moment a new one is issued.

`onehux_sso.TokenExpiredError` is still the error you catch, but its meaning is now "not
signed in, full stop" rather than "the 15-minute access token died" — it's raised only once a
refresh has already been attempted and failed too (or no refresh token was ever stored, e.g. a
session from before this package version). In every one of those cases, catch it and send the
user back through `client.start_authorization()` for a fresh login. The backend deliberately
does not tell this package *why* a refresh failed — ordinary expiry, an already-rotated token
being replayed (a real reuse/compromise signal), or the underlying session being revoked all
produce the same generic rejection (RFC 9700 §4.14.2's own reasoning: the server can't tell
which party presented the stale token) — so this package has nothing more specific to offer a
caller than "not valid anymore."

If you call `client.get_userinfo()` yourself outside of `UserInfoView`/the decorator/the mixin
(see "Using the client directly" above), it never retries on your behalf — it's a pure API
call with no session concept. Catch `TokenExpiredError`, call `client.refresh_access_token()`
yourself if you have a stored refresh token, persist the newly-rotated one, and retry once.

**Public clients** (a future mobile/desktop SDK, no `client_secret`) get tighter refresh-token
settings than this package's confidential-client model (7-day idle timeout / 14-day absolute
lifetime vs. 30/30 here) — not relevant to this package today, but worth knowing the number
"30 days" below isn't a platform-wide constant.

## Protecting your own views — `onehux_login_required` / `OneHuxLoginRequiredMixin`

`UserInfoView` and this package's own example app both catch `TokenExpiredError` themselves,
but if you're wiring up your own protected views (as the "Using the client directly" section
above shows), you have to catch it yourself every single time — miss it once and an expired
token becomes an unhandled exception (a real 500) instead of a clean redirect back to sign-in.

`onehux_login_required` (function views) and `OneHuxLoginRequiredMixin` (class-based views)
close that gap: both redirect to `/auth/login/?next=<original path>` if there's no access
token in the session yet, **and** catch `TokenExpiredError` raised anywhere during the view's
execution. On that error, both now attempt one silent refresh (see "Refresh tokens" above)
using the session's stored refresh token first — success redirects back to the *same URL* (a
fresh GET re-runs your view with a valid session, rather than re-invoking your view function
in-process, which this package doesn't control and can't assume is safe to run twice);
failure (or no refresh token stored) clears the dead access token and redirects to
`/auth/login/` exactly as before this feature existed.

```python
from onehux_sso import OneHuxClient, onehux_login_required
from onehux_sso.conf import get_setting

@onehux_login_required
def dashboard(request):
    client = OneHuxClient.from_settings()
    access_token = request.session[get_setting("SESSION_ACCESS_TOKEN_KEY")]
    claims = client.get_userinfo(access_token=access_token)  # TokenExpiredError -> refresh-and-retry, or redirect
    ...
```

```python
from django.views import View
from onehux_sso import OneHuxClient, OneHuxLoginRequiredMixin
from onehux_sso.conf import get_setting

class DashboardView(OneHuxLoginRequiredMixin, View):  # mixin first, before View
    def get(self, request):
        client = OneHuxClient.from_settings()
        access_token = request.session[get_setting("SESSION_ACCESS_TOKEN_KEY")]
        claims = client.get_userinfo(access_token=access_token)
        ...
```

## Your Django session cookie lifetime vs. the access token

This package never sets `SESSION_COOKIE_AGE` — it relies entirely on whatever your own Django
project has configured (Django's own default is 2 weeks). That's deliberate: session cookie
lifetime is your project's own call to make, not something an SSO client should override out
from under you. The two lifetimes are still **independent**, but "how long is the user
actually signed in" is no longer a flat 15 minutes either: `UserInfoView` and
`onehux_login_required`/`OneHuxLoginRequiredMixin` now silently refresh an expired access
token using the stored refresh token (see "Refresh tokens" above), so a signed-in user's real
session length is bounded by the refresh token's own lifetime (30 days for a confidential
client like this one; see the backend repo's README.md ADR-081) — not by the access token's 15
minutes, and *also* not by `SESSION_COOKIE_AGE`. If your cookie outlives the refresh token
itself, the cookie will still exist but `TokenExpiredError` will eventually fire anyway once
the refresh token itself expires or is rejected — that's still real and still possible, just
on a longer, rotation-extended clock instead of a flat 15 minutes. Don't build any logic that
assumes "the user has a session cookie" implies "the user has a valid access token" — always
call `client.get_userinfo()` (directly, or via the protection helpers above) to find out, and
treat `TokenExpiredError` as the real source of truth.

## Example project

See `example/` for a complete, runnable Django project using this package end-to-end —
registered against a real disposable test `Application` and actually run through the full
browser flow against production, not just unit-tested in isolation.

## License

Apache License 2.0 — see `LICENSE`.
