Metadata-Version: 2.4
Name: onehux-sso
Version: 0.3.0
Summary: Django SDK for OneHux Accounts SSO — Authorization Code + PKCE BFF integration, AND a resource-server mode (onehux_sso.resource_server) for a Django API sitting behind a separate frontend.
License: Apache-2.0
Project-URL: Homepage, https://accounts.onehux.com
Project-URL: Documentation, https://accounts.onehux.com/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: resource-server
Requires-Dist: djangorestframework>=3.14; extra == "resource-server"
Requires-Dist: PyJWT[crypto]>=2.8; extra == "resource-server"
Provides-Extra: test
Requires-Dist: pytest>=8.0; extra == "test"
Requires-Dist: pytest-django>=4.8; extra == "test"
Requires-Dist: djangorestframework>=3.14; extra == "test"
Requires-Dist: PyJWT[crypto]>=2.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/docs/integrate/backend/django)
otherwise only shows as copy-paste example code.

## Which pattern applies to you — read this before installing anything

This package solves two genuinely different problems, and a real, reported integration failure
happened because that wasn't obvious: a developer building a dedicated Django API behind a
separate frontend installed this package expecting it to work as a resource server, wired it up
the only way the docs showed (Django as the OAuth client, holding its own session), and hit an
immediate, unexplained logout even while still signed in at the IdP. That wasn't a bug in their
code — the pattern they needed didn't exist in this package at all until now.

**Is Django your BFF** — does it hold its own session, redirect a browser to OneHux's hosted
login page, and exchange the resulting code itself (a traditional Django+templates app, or a
Django app that's the *only* backend, with no separate frontend framework in front of it)?
→ Use the rest of this README: `client.py`/`views.py`/`decorators.py`, `INSTALLED_APPS`,
`ONEHUX_SSO` settings, mounted URLs. Django is the confidential OAuth client.

**Is a separate frontend your BFF** — SvelteKit, Next.js, Express, Nuxt, Remix, Astro, or any
other Node-based frontend that already handles the login redirect/token exchange/refresh itself
(see [`@onehux/sso`](https://www.npmjs.com/package/@onehux/sso) and
[the Node integration guide](https://accounts.onehux.com/docs/integrate/backend/node)), and
Django is *only* an API that frontend calls with `Authorization: Bearer <token>`?
→ Skip everything below except this section. You want **`onehux_sso.resource_server`** instead
— a real DRF authentication class that verifies a Bearer token's signature against the
platform's own published JWKS, with zero session/redirect/client_secret involved:

```bash
pip install "onehux-sso[resource-server]"
```

```python
# settings.py
REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": [
        "onehux_sso.resource_server.OneHuxResourceServerAuthentication",
    ],
}
```

```python
# views.py
from rest_framework.permissions import IsAuthenticated
from rest_framework.views import APIView
from rest_framework.response import Response

class MyProtectedView(APIView):
    permission_classes = [IsAuthenticated]

    def get(self, request):
        # request.user is a OneHuxRemoteUser — no local Users table needed, the identity
        # lives entirely on the OneHux platform. request.user.email, .org_id, .sub, .scope
        # are all real claims from the verified token.
        return Response({"email": request.user.email, "org_id": request.user.org_id})
```

No `ONEHUX_SSO` settings dict needed for this pattern at all — see `onehux_sso/resource_server.py`'s
own module docstring for the full design (why a `OneHuxRemoteUser`, not a real Django `User`;
the optional `TRUSTED_CLIENT_IDS` tenant-isolation setting; the `HasOneHuxScope` permission
class). `INSTALLED_APPS` still needs `"onehux_sso"` (the app config itself is a no-op — no
models, no signals — but Django requires it to be listed for the package to import cleanly).

## Install (the Django-is-your-BFF pattern)

```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`.
