From c8aa9fcf5fd541190e9e95e4ba144e9309fa1a69 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Sat, 20 Jun 2026 03:40:07 +0500 Subject: [PATCH 1/2] docs: add enterprise auth documentation section Add docs for the OIDC AuthPlugin covering the secure-by-default model, providers, custom auth pages, and testing guarded code. Register the new pages in the enterprise sidebar, add an Authentication category to the enterprise overview, and whitelist the section for preview. --- .../sidebar/sidebar_items/enterprise.py | 25 ++ docs/app/reflex_docs/whitelist.py | 5 +- docs/enterprise/auth/custom-pages.md | 165 +++++++++ docs/enterprise/auth/overview.md | 119 ++++++ docs/enterprise/auth/providers.md | 254 +++++++++++++ docs/enterprise/auth/secure-by-default.md | 339 ++++++++++++++++++ docs/enterprise/auth/testing.md | 232 ++++++++++++ docs/enterprise/overview.md | 42 +++ 8 files changed, 1180 insertions(+), 1 deletion(-) create mode 100644 docs/enterprise/auth/custom-pages.md create mode 100644 docs/enterprise/auth/overview.md create mode 100644 docs/enterprise/auth/providers.md create mode 100644 docs/enterprise/auth/secure-by-default.md create mode 100644 docs/enterprise/auth/testing.md diff --git a/docs/app/reflex_docs/templates/docpage/sidebar/sidebar_items/enterprise.py b/docs/app/reflex_docs/templates/docpage/sidebar/sidebar_items/enterprise.py index 9ae3e6be5e6..6e833f7bce5 100644 --- a/docs/app/reflex_docs/templates/docpage/sidebar/sidebar_items/enterprise.py +++ b/docs/app/reflex_docs/templates/docpage/sidebar/sidebar_items/enterprise.py @@ -34,6 +34,31 @@ def get_sidebar_items_enterprise_usage(): ), ], ), + SideBarItem( + names="Authentication", + children=[ + SideBarItem( + names="Overview", + link=enterprise.auth.overview.path, + ), + SideBarItem( + names="Secure by Default", + link=enterprise.auth.secure_by_default.path, + ), + SideBarItem( + names="OIDC Providers", + link=enterprise.auth.providers.path, + ), + SideBarItem( + names="Customizing the Auth Pages", + link=enterprise.auth.custom_pages.path, + ), + SideBarItem( + names="Testing Guarded Code", + link=enterprise.auth.testing.path, + ), + ], + ), ] diff --git a/docs/app/reflex_docs/whitelist.py b/docs/app/reflex_docs/whitelist.py index 898784703ce..ffe91bfe749 100644 --- a/docs/app/reflex_docs/whitelist.py +++ b/docs/app/reflex_docs/whitelist.py @@ -11,7 +11,10 @@ """ WHITELISTED_PAGES = [ - # "/getting-started/introduction", + # Auth docs preview — matches all 5 pages under /enterprise/auth/ by prefix, + # plus the enterprise overview landing page so navigation into the section works. + "/enterprise/auth", + "/enterprise/overview", ] diff --git a/docs/enterprise/auth/custom-pages.md b/docs/enterprise/auth/custom-pages.md new file mode 100644 index 00000000000..b95ab78a5b6 --- /dev/null +++ b/docs/enterprise/auth/custom-pages.md @@ -0,0 +1,165 @@ +--- +title: Customizing the Auth Pages +--- + +_New in reflex-enterprise v0.9.1._ + +# Customizing the Auth Pages + +`rxe.AuthPlugin` registers three friendly routes and owns their **wiring**: + +| Endpoint | Default route | Plugin-owned wiring | +| --- | --- | --- | +| `login_endpoint` | `/login` | Renders the login palette / starts the OIDC redirect. | +| `auth_callback_endpoint` | `/callback` | CSRF (OAuth `state`) check + authorization-code token exchange, then redirect back. | +| `logout_endpoint` | `/logout` | Dispatches the active provider's logout. | + +You can replace only the **rendered component** on each route via the +`login_page`, `callback_page`, and `logout_page` builders — the `on_load` +wiring (login redirect, callback token exchange, logout dispatch) stays +plugin-owned, so the real OIDC flow is never something you reimplement. + +The routes themselves are configurable through `login_endpoint`, +`logout_endpoint`, and `auth_callback_endpoint` (defaults `/login`, `/logout`, +`/callback`). See the [providers](/docs/enterprise/auth/providers/) page for +configuring identity providers, and the +[overview](/docs/enterprise/auth/overview/) for how the plugin fits together. + +```md alert warning +# Register the callback URI with your IdP +If you change `auth_callback_endpoint`, register that exact URI as the OAuth redirect URI with your identity provider, or the token exchange will be rejected. +``` + +## The page builder contract + +A page builder is a callable that receives the build context as **keyword +arguments**: + +| Keyword | Type | Meaning | +| --- | --- | --- | +| `providers` | `Sequence[type[OIDCAuthState]]` | The resolved provider state classes. | +| `plugin` | `AuthPlugin` | The plugin instance. | + +Name the entries you need and add `**context` to ignore the rest: + +```python +import reflex as rx + + +def custom_login_page(providers, **context) -> rx.Component: ... +``` + +A builder may also take all of it with `**context` only. The same contract +applies to the login, callback, and logout builders. + +## A custom login page + +The login builder wraps each provider's `get_login_button(*children)` so the +real OIDC redirect wiring is unchanged — only the surrounding layout is yours. +Loop over `providers` and pass the clickable element you want as the button's +children. `provider.display_name()` returns a pretty name (it defaults to the +provider's `__provider__` title-cased): + +```python +import reflex as rx + + +def custom_login_page(providers, **context) -> rx.Component: + return rx.center( + rx.vstack( + *[ + provider.get_login_button( + rx.button(f"Continue with {provider.display_name()}") + ) + for provider in providers + ], + ), + ) +``` + +With two or more providers this naturally renders one button per provider — a +login palette where the visitor picks an identity provider. + +## Custom callback and logout pages + +The callback and logout routes only show an interstitial while their +plugin-owned `on_load` runs. Reuse `providers[0].get_authentication_loading_page()`, +which already shows the validating and redirecting states as the exchange (or +logout) proceeds — and an error view if it fails: + +```python +import reflex as rx + + +def custom_callback_page(providers, **context) -> rx.Component: + return providers[0].get_authentication_loading_page() + + +def custom_logout_page(providers, **context) -> rx.Component: + return providers[0].get_authentication_loading_page() +``` + +Wrap that view in your own layout to brand the interstitial — for example a +centered card with a heading above the loading view. + +## Wiring them up + +Pass the builders to the plugin in `rxconfig.py` as **import-path strings** +(`"module.function"`). The builder modules import `reflex_enterprise`, which +loads `rxconfig` at import time, so importing them in `rxconfig.py` would +re-enter the config; the plugin resolves the strings lazily at compile time +instead: + +```python +import reflex as rx + +import reflex_enterprise as rxe + +config = rxe.Config( + app_name="my_app", + plugins=[ + rxe.AuthPlugin( + login_page="my_app.auth_pages.custom_login_page", + callback_page="my_app.auth_pages.custom_callback_page", + logout_page="my_app.auth_pages.custom_logout_page", + ), + ], +) +``` + +```md alert info +# Strings in rxconfig, callables elsewhere +The import-path string is only required because of the rxconfig re-entry. If you build the `AuthPlugin` somewhere the builder is already importable, you can pass the callable directly: `login_page=custom_login_page`. +``` + +## Defaults + +Omit a builder and the plugin falls back to its defaults from +`reflex_enterprise.auth.pages`: + +| Builder argument | Default | Renders | +| --- | --- | --- | +| `login_page` | `default_login_page` | One `provider.get_login_button()` per provider. | +| `callback_page` | `default_callback_page` | `providers[0].get_authentication_loading_page()`. | +| `logout_page` | `default_logout_page` | `providers[0].get_authentication_loading_page()`. | + +The defaults take the same `providers` / `plugin` keyword context, so a custom +builder may call one to wrap the default content in its own layout: + +```python +import reflex as rx + +from reflex_enterprise.auth.pages import default_login_page + + +def custom_login_page(providers, **context) -> rx.Component: + return rx.center(default_login_page(providers=providers, **context)) +``` + +## Related + +- [Providers](/docs/enterprise/auth/providers/) — configure the identity + providers the login page renders buttons for. +- [Testing](/docs/enterprise/auth/testing/) — verify guarded surfaces. +- [Secure by default](/docs/enterprise/auth/secure-by-default/) — how the rest + of the app is protected. diff --git a/docs/enterprise/auth/overview.md b/docs/enterprise/auth/overview.md new file mode 100644 index 00000000000..bfa4831ef9a --- /dev/null +++ b/docs/enterprise/auth/overview.md @@ -0,0 +1,119 @@ +--- +title: Authentication Overview +--- + +_New in reflex-enterprise v0.9.1._ + +# Authentication Overview + +`rxe.AuthPlugin` adds OIDC (OpenID Connect) authentication to your Reflex app +with a **secure-by-default** model. Once the plugin is in +`rxe.Config(plugins=[...])`, four surfaces require a logged-in user unless you +explicitly opt out: **pages** (anonymous visitors are redirected to login), +**event handlers** (anonymous callers are blocked and redirected), **base state +fields** (dropped from the state delta until login), and **computed vars** +(withheld until login). The plugin runs the real OIDC Authorization Code + PKCE +flow against your identity provider and auto-registers friendly `/login`, +`/logout`, and `/callback` routes. + +```md alert warning +# Requirements +Requires `reflex-enterprise` with the auth plugin (v0.9.1+). Your app must use `rxe.App()` (not `rx.App()`), and you must configure an OIDC identity provider via environment variables. +``` + +## Quickstart + +Add `rxe.AuthPlugin()` to the `plugins` list of `rxe.Config` in `rxconfig.py`, +and configure your OIDC provider through the `OIDC_*` environment variables: + +```python +import os + +import reflex as rx +import reflex_enterprise as rxe + +os.environ.setdefault("OIDC_ISSUER_URI", "https://your-idp.example.com") +os.environ.setdefault("OIDC_CLIENT_ID", "your-client-id") +os.environ.setdefault("OIDC_CLIENT_SECRET", "your-client-secret") + +config = rxe.Config( + app_name="my_app", + plugins=[ + rxe.AuthPlugin(), + ], +) +``` + +Your app must use `rxe.App()` (not `rx.App()`): + +```python +import reflex_enterprise as rxe + +app = rxe.App() +``` + +With the `OIDC_*` variables set you need **no custom provider** — the plugin +defaults `auth_providers` to `[GenericOIDCAuthState]`, which reads +`OIDC_ISSUER_URI`, `OIDC_CLIENT_ID`, and `OIDC_CLIENT_SECRET`. Register the +plugin's `auth_callback_endpoint` (`/callback` by default) as the redirect URI +with your IdP. See [providers](/docs/enterprise/auth/providers/) for named and +multi-provider setups. + +## The four protected surfaces + +Each surface is protected by default and has its own way to opt out or gate: + +| Surface | Default | Opt out / gate | +| --- | --- | --- | +| Pages (`@rxe.page` / `app.add_page` / `@rx.page`) | login required | `@rxe.page(auth=False)` or `app.add_page(..., auth=False)` | +| Event handlers (`@rxe.event`) | login required | `@rxe.event(auth=False)` or `@rxe.event(auth=)` | +| Base fields (`rxe.field` / plain `rx.field`) | withheld until login | `rxe.field(default, auth=False)` | +| Computed vars (`@rxe.var`) | withheld until login | `@rxe.var(auth=False)` | + +`auth=True` is the secure default on every surface, so a plain `rx.field(...)` +or a bare `@rxe.var` on a non-exempt state is already protected. Pass +`auth=False` to opt a surface out and make it public. Event handlers and +fields/vars also accept a **callable** authorization check that runs only after +authentication succeeds; pages take `auth` as a bool only. See +[secure-by-default](/docs/enterprise/auth/secure-by-default/) for the full +enforcement model and check-function signatures. + +## Reading the current user + +`reflex_enterprise.auth.User` is a facade over the active provider for reading +the current user. Its class-level Vars (`User.name`, `User.email`, and the rest) +embed directly in components, each typed `str | None`. Inside an event handler, +`await User.current()` returns the current user's `OIDCUserInfo` dict (or `None` +when anonymous): + +```python +import reflex as rx +import reflex_enterprise as rxe +from reflex_enterprise.auth import User + + +class DemoState(rx.State): + @rxe.event # default auth=True + async def protected_action(self): + user = await User.current() or {} + return rx.toast(f"Hello {user.get('name') or user.get('sub')}!") +``` + +In components, render the Vars directly, e.g. `rx.text(User.name)` or +`rx.avatar(src=User.picture)`. See +[secure-by-default](/docs/enterprise/auth/secure-by-default/) for more on the +`User` facade and how protected values are delivered after login. + +## Learn more + +- [Secure by default](/docs/enterprise/auth/secure-by-default/) — the + enforcement model, the four `auth=` wrappers, check functions, and the `User` + facade. +- [Providers](/docs/enterprise/auth/providers/) — `GenericOIDCAuthState`, named + and multi-provider setups, and OIDC environment variables. +- [Custom pages](/docs/enterprise/auth/custom-pages/) — replacing the rendered + `/login`, `/callback`, and `/logout` components with your own builders. +- [Testing](/docs/enterprise/auth/testing/) — exercising guarded surfaces with + `auth_as`. +- [Enterprise overview](/docs/enterprise/overview/) — the rest of + reflex-enterprise. diff --git a/docs/enterprise/auth/providers.md b/docs/enterprise/auth/providers.md new file mode 100644 index 00000000000..ad5cdfc1ade --- /dev/null +++ b/docs/enterprise/auth/providers.md @@ -0,0 +1,254 @@ +--- +title: OIDC Providers +--- + +_New in reflex-enterprise v0.9.1._ + +# OIDC Providers + +An OIDC provider is the state class that runs the OpenID Connect Authorization +Code + PKCE flow against your identity provider (IdP). `rxe.AuthPlugin` ships a +built-in provider and resolves all of its configuration from environment +variables, so the common case needs no provider code at all. This page covers +the default provider, naming your own, the environment variables each one reads, +registering providers with the plugin, scopes and refresh tokens, running +several providers at once, customizing the user-info claims, and the advanced +hooks you can override. + +See [secure-by-default](/docs/enterprise/auth/secure-by-default/) for how the +plugin protects pages, events, fields, and computed vars, and the +[overview](/docs/enterprise/auth/overview/) for the big picture. + +## The default provider + +`GenericOIDCAuthState` is the built-in provider. It reads three environment +variables: + +```bash +OIDC_ISSUER_URI=https://your-issuer.example.com +OIDC_CLIENT_ID=your-client-id +OIDC_CLIENT_SECRET=your-client-secret +``` + +`AuthPlugin.auth_providers` defaults to `[GenericOIDCAuthState]`, so with the +`OIDC_*` variables set you do not need to write or register a custom provider: + +```python +import reflex as rx +import reflex_enterprise as rxe + +config = rxe.Config( + app_name="my_app", + plugins=[rxe.AuthPlugin()], # uses GenericOIDCAuthState + OIDC_* env vars +) +``` + +`GenericOIDCAuthState` declares a nested `UserInfo` `TypedDict` describing the +standard claims it covers — `sub`, `name`, `email`, `picture`, and +`groups` — on top of the base `OIDCUserInfo`: + +```python +class GenericOIDCAuthState(OIDCAuthState, rx.State): + __provider__ = "generic" + + class UserInfo(OIDCUserInfo, total=False): + groups: list[str] + name: str + email: str + picture: str +``` + +## Naming a provider + +To use provider-specific environment variables (and to register multiple +distinct IdPs), subclass `OIDCAuthState` and set `__provider__`: + +```python +import reflex as rx +from reflex_enterprise.auth.oidc.state import OIDCAuthState + + +class OktaAuthState(OIDCAuthState, rx.State): + __provider__ = "okta" +``` + +With `__provider__ = "okta"`, config resolution prefers the +`OKTA_CLIENT_ID` / `OKTA_CLIENT_SECRET` / `OKTA_ISSUER_URI` variables, falling +back to the shared `OIDC_*` keys: + +```bash +OKTA_ISSUER_URI=https://your-org.okta.com +OKTA_CLIENT_ID=your-okta-client-id +OKTA_CLIENT_SECRET=your-okta-client-secret +``` + +Render a login button for the provider with its `get_login_button()` +classmethod (pass children to customize the clickable element): + +```python +def login() -> rx.Component: + return OktaAuthState.get_login_button() +``` + +## Environment variables + +Each provider resolves every config key by trying the provider-specific +`{PROVIDER}_{KEY}` variable first, then falling back to the shared `OIDC_{KEY}` +variable. `{PROVIDER}` is the uppercased `__provider__` value. + +| Key | Provider-specific | Shared fallback | Notes | +| --- | --- | --- | --- | +| Issuer | `{PROVIDER}_ISSUER_URI` | `OIDC_ISSUER_URI` | The IdP issuer URL. | +| Client ID | `{PROVIDER}_CLIENT_ID` | `OIDC_CLIENT_ID` | The OAuth client id. | +| Client Secret | `{PROVIDER}_CLIENT_SECRET` | `OIDC_CLIENT_SECRET` | Optional — PKCE works without it. | + +The default `GenericOIDCAuthState` (`__provider__ = "generic"`) resolves +`GENERIC_*` then `OIDC_*`, so in practice you only set the `OIDC_*` keys for it. +Register the plugin's `auth_callback_endpoint` URI (default `/callback`) with +your IdP as the redirect URI. + +## Registering providers with the plugin + +`AuthPlugin(auth_providers=[...])` accepts either provider **classes** or +`"module.ClassName"` import-path **strings** (resolved lazily at compile time). +Order is preserved, and the two forms may be mixed. The default is +`[GenericOIDCAuthState]`. + +```md alert warning +# Use import-path strings in `rxconfig.py` +Provider modules import `reflex_enterprise`, which loads `rxconfig` at import +time. Importing a provider class directly in `rxconfig.py` would re-enter the +config. Pass providers as `"module.ClassName"` strings there so the plugin can +resolve them lazily once the config exists. +``` + +In `rxconfig.py`, pass strings: + +```python +import reflex_enterprise as rxe + +config = rxe.Config( + app_name="my_app", + plugins=[ + rxe.AuthPlugin( + auth_providers=["my_app.auth.OktaAuthState"], + ), + ], +) +``` + +Outside `rxconfig.py` (for example, in tests), you may pass the classes +themselves: + +```python +from my_app.auth import OktaAuthState + +rxe.AuthPlugin(auth_providers=[OktaAuthState]) +``` + +## Scopes and refresh tokens + +`extra_scopes` is forwarded to every configured provider and merged into the +scopes each one requests. The merge is deduped and preserves any existing scopes +(including `openid` and `offline_access`): + +```python +rxe.AuthPlugin( + auth_providers=["my_app.auth.OktaAuthState"], + extra_scopes=["offline_access"], +) +``` + +- `extra_scopes=["offline_access"]` asks the IdP to issue a **refresh token**. + Once a refresh token is granted, the framework refreshes the access token + automatically and proactively as it nears expiry. +- `extra_scopes=["groups"]` requests group claims, useful for authorization + checks against `userinfo.get("groups")`. + +## Multiple providers + +With two or more providers, `/login` shows a palette — one button per provider — +and the visitor clicks to choose; there is no automatic redirect to a single +IdP. The callback resolves the **initiating** provider (via the OAuth `state` +parameter), and logout resolves the **active** provider (the one currently +holding tokens). + +```python +rxe.AuthPlugin( + auth_providers=[ + "my_app.auth.OktaAuthState", + "my_app.auth.AzureAuthState", + ], +) +``` + +```md alert warning +# Give each provider its own config when running more than one +If two or more providers would both fall back to the shared `OIDC_*` config for +a required key (issuer or client id), distinct identity providers would silently +collapse onto one value. The plugin raises a `ConfigError` at wiring time naming +the offending providers. Set a provider-specific `{PROVIDER}_*` variable +(e.g. `OKTA_ISSUER_URI`, `AZURE_ISSUER_URI`) for each. +``` + +## Customizing claims + +`OIDCUserInfo` is a `TypedDict` (`total=False`) with a single `sub` key; it is a +plain dict at runtime, so you read claims with `.get(...)`. To document the +extra claims a provider returns, declare a nested +`UserInfo(OIDCUserInfo, total=False)` on your provider — exactly as +`GenericOIDCAuthState.UserInfo` does: + +```python +from reflex_enterprise.auth.oidc.state import OIDCAuthState +from reflex_enterprise.auth.oidc.types import OIDCUserInfo + + +class OktaAuthState(OIDCAuthState, rx.State): + __provider__ = "okta" + + class UserInfo(OIDCUserInfo, total=False): + name: str + email: str + groups: list[str] +``` + +## Advanced extension points + +`OIDCAuthState` exposes a set of overridable async hooks for advanced cases. +Most apps never need to touch these — the defaults run the standard flow. Each +is a method you override on your provider subclass: + +- `_validate_tokens(self) -> bool` — validate the current access and ID tokens; + return whether they are valid. +- `_verify_jwt(self, token_json) -> Token` — verify the ID token JWT; override + to customize verification. +- `_valid_issuers(self) -> list[str] | None` — return the acceptable `iss` claim + values; override for cases like Azure multi-tenant. +- `_set_tokens(self, access_token, id_token=None, refresh_token=None, granted_scopes=None, **kwargs)` + — persist the tokens after a successful exchange; override to handle extra + data from the token response. +- `_validate_auth_callback_exchange(self, exchange) -> dict | None` — validate + the token-exchange response from the callback. +- `_fetch_userinfo(self) -> OIDCUserInfo` — fetch the claims from the IdP's + userinfo endpoint; override to fetch or reshape the claims differently. +- `_on_access_token_change(self, new_access_token, refresh=False)` — react when + the access token is set or refreshed. +- `_on_refresh_access_token(self, new_access_token)` — react specifically when + the access token is refreshed. + +## Migrating from `register_auth_endpoints` + +`OIDCAuthState.register_auth_endpoints(app)` is deprecated (since +reflex-enterprise v0.9.1, removed in 1.0). Register `rxe.AuthPlugin` in +`rxe.Config(plugins=[...])` instead — it wires the `/login`, `/logout`, and +`/callback` routes (and the secure-by-default protections) automatically. + +## Related + +- [Custom pages](/docs/enterprise/auth/custom-pages/) — replace the login / + callback / logout page builders. +- [Testing](/docs/enterprise/auth/testing/) — exercise guarded surfaces against + an injected user. +- [Secure by default](/docs/enterprise/auth/secure-by-default/) — how the plugin + protects pages, event handlers, fields, and computed vars. diff --git a/docs/enterprise/auth/secure-by-default.md b/docs/enterprise/auth/secure-by-default.md new file mode 100644 index 00000000000..137a3c53b23 --- /dev/null +++ b/docs/enterprise/auth/secure-by-default.md @@ -0,0 +1,339 @@ +--- +title: Secure by Default +--- + +_New in reflex-enterprise v0.9.1._ + +# Secure by Default + +Once `rxe.AuthPlugin` is configured (see the [auth overview](/docs/enterprise/auth/overview/)), +**every** non-exempt page, event handler, base field, and computed var in your +app requires a logged-in user — unless you explicitly opt it out. You don't +mark things as protected; they start protected, and you open up exactly the +surfaces that should be public. + +Every `rxe.*` wrapper takes the same `auth=` argument, whose value means: + +| `auth=` value | Meaning | +| --- | --- | +| `True` | Require an authenticated user. **This is the secure default for every surface.** | +| `False` | Public — allow everyone (opt out of protection). | +| a callable check | An authorization check that runs **only after** authentication succeeds. Truthy result allows; a falsey result or a raised exception denies. | + +The four wrappers are exported at top level: `rxe.page`, `rxe.event`, +`rxe.field`, and `rxe.var`. The rest of this page covers each surface, then the +shared enforcement semantics and how to read the current user. + +```md alert warning +# Requires `rxe.App` and a configured provider +Secure-by-default only applies when your app uses `rxe.App()` (not `rx.App()`) and `rxe.AuthPlugin` is in `rxe.Config(plugins=[...])` with an OIDC identity provider configured via env vars. See the [overview](/docs/enterprise/auth/overview/). +``` + +## Pages + +Protect a page with `@rxe.page`. For pages, `auth` is a **bool only** — callable +checks are not supported here. + +```python +@rxe.page( + route: str | None = None, + *, + auth: bool = True, + **page_kwargs, +) +``` + +A protected page (`auth=True`, the default) injects +`PageGuardState.enforce_login` as the **first** `on_load` event. Anonymous +visitors are redirected to the login endpoint, and the page they were trying to +reach is preserved as a `redirect_to` query parameter so the post-login flow +returns them there. + +```python +@rxe.page(route="/dashboard", title="Dashboard") # auth=True is the default +def dashboard() -> rx.Component: + """Protected page: anonymous visitors are redirected to /login.""" + ... +``` + +Set `auth=False` for a public page: + +```python +@rxe.page(route="/", title="Home", auth=False) +def index() -> rx.Component: + """Public landing page (opted out of secure-by-default).""" + ... +``` + +Any extra `**page_kwargs` are forwarded verbatim to `rx.page` — `title`, +`image`, `description`, `meta`, `script_tags`, and `on_load`. When you also pass +`on_load`, the login guard is prepended to it (so it always runs before your own +on-load events). + +### Every page is protected, not just `@rxe.page` ones + +With the plugin on, `rxe.App()` defaults every page to login-required — pages +added via `app.add_page(...)` or plain `@rx.page` are guarded too. Opt out with +`auth=False`: + +```python +app.add_page(index, route="/", auth=False) +``` + +Plain `@rx.page` takes no `auth` argument, so opt a decorated page out with +`@rxe.page(auth=False)`. + +## Event handlers + +Protect an event handler with `@rxe.event`. Here `auth` accepts a bool or a +callable check. + +```python +@rxe.event( + fn=None, + *, + auth: bool | Callable = True, + **event_kwargs, +) +``` + +Works bare (`@rxe.event`) or called (`@rxe.event(auth=...)`), and can wrap a raw +function or an already-converted `EventHandler`. Extra `**event_kwargs` are +forwarded to `rx.event`: `background`, `stop_propagation`, `prevent_default`, +`throttle`, `debounce`, and `temporal`. + +```python +class DemoState(rx.State): + @rxe.event # default auth=True: anonymous callers are redirected to /login + async def protected_action(self): + """Greet the logged-in user, resolved from the backend userinfo.""" + user = await User.current() or {} + return rx.toast(f"Hello {user.get('name') or user.get('sub')}!") + + @rxe.event(auth=False) + def toggle_loading(self): + """A public handler anyone may call.""" + self.loading = not self.loading +``` + +For finer-grained control, pass a check with the signature +`func(handler, payload, userinfo) -> bool`. It runs only after the caller is +authenticated; an anonymous caller is redirected to login first and never reaches +the check. + +```python +def _is_admin(handler, payload, userinfo) -> bool: + """Event authz check: allow only members of the ``admins`` group.""" + return "admins" in (userinfo.get("groups") or []) + + +class DemoState(rx.State): + @rxe.event(auth=_is_admin) # authz failure -> "Action not allowed" toast + def admin_action(self): + """An action only members of the ``admins`` group may run.""" + return rx.toast.success("Admin action executed.") +``` + +## Base fields + +Base (state) fields are protected by default too. A plain `rx.field(...)` — or a +bare annotation — on a non-exempt state class is **already** protected: it is +dropped from the state delta until the user logs in. You only reach for +`rxe.field` when you want to opt a field out or attach a check. + +```python +def field( + default=..., + *, + auth: bool | Callable = True, + default_factory=None, + is_var=True, +) +``` + +```python +class DemoState(rx.State): + # Base fields are protected by default: dropped from the delta until login. + notes: rx.Field[str] = rx.field("These notes are only sent once you log in.") + # Explicitly public field, always sent to the client. + loading: rx.Field[bool] = rxe.field(False, auth=False) +``` + +A field check has the signature `func(field, userinfo) -> bool`: + +```python +def _is_admin(field, userinfo) -> bool: + return bool(userinfo) and "admin" in (userinfo.get("groups") or []) + + +class DemoState(rx.State): + audit_log: rx.Field[list[str]] = rxe.field([], auth=_is_admin) +``` + +A protected field is dropped from the state delta until the user is resolved, +then re-delivered (see [How withholding works](#how-withholding-works)). + +## Computed vars + +Computed vars are protected by default and withheld from the delta until login. +Wrap them with `@rxe.var`. + +```python +def var( + fget=None, + *, + auth: bool | Callable = True, + **var_kwargs, +) +``` + +Usable bare (`@rxe.var`) or called (`@rxe.var(auth=..., initial_value=...)`). +Extra `**var_kwargs` are forwarded verbatim to `rx.var`: `initial_value`, +`cache`, `deps`, `auto_deps`, `interval`, and `backend`. + +```md alert info +# Always pair a protected var with `initial_value` +Because a protected var is withheld until the user is resolved, the client has no value to render in the meantime. Set `initial_value=` to a placeholder that is baked into the frontend bundle and shown until the real value is delivered after login. +``` + +```python +class DemoState(rx.State): + @rxe.var(initial_value="🔒 (log in to reveal this protected computed var)") + def protected_tip(self) -> str: + """Protected by default: the placeholder shows until a user is resolved.""" + return "✅ This computed var is delivered only to logged-in users." + + @rxe.var(auth=False) + def public_label(self) -> str: + """A computed var opened up to anonymous visitors.""" + return "This text is public — anyone can read it." +``` + +A var check has the signature `func(var, userinfo) -> bool`, and (as with +fields) pairs well with `initial_value`: + +```python +class DemoState(rx.State): + @rxe.var(auth=_is_admin, initial_value=0) + def pending_approvals(self) -> int: ... +``` + +## Authentication vs authorization + +The two failure modes are deliberately different. Think of it as a decision tree +applied per surface against the resolved user: + +| Situation | Outcome | +| --- | --- | +| `auth=False` | **Allow.** | +| Not logged in (no user resolved) | **Redirect to login** (authentication failure), before any check runs. | +| Logged in and `auth=True` | **Allow.** | +| Logged in and the check returns truthy | **Allow.** | +| Logged in and the check returns falsey or raises | **"Action not allowed" toast** (authorization failure) — never a login redirect. | + +An **authentication** failure (not logged in) always redirects to the login +endpoint. An **authorization** failure (a check said no) shows the default +`"Action not allowed"` toast and never redirects — redirecting an +already-logged-in user to login would just loop. + +Two properties follow from the ordering: a check **never runs for an anonymous +caller** (the redirect happens first, so `userinfo` is always present inside a +check), and a check that **raises fails closed** (the exception is treated as a +deny, not an allow). + +## How withholding works + +Protected base fields are dropped from the state delta, and protected computed +vars are skipped, for any caller who isn't authorized to see them. + +The subtlety is timing. The `hydrate` event runs **before** the auth cookies are +known, so even for a user who is logged in, protected values are withheld at +first — the user simply hasn't been resolved yet that early. Once an event +resolves an authenticated user (the page guard on a protected page does this), +the protected names are re-delivered in that event's delta, filtered against the +now-resolved user. + +This is exactly why protected computed vars should set `initial_value`: that +placeholder is baked into the frontend bundle and shown until the real value +arrives after login. + +## Logout resets protected state + +On logout, each non-exempt state's **protected** surface is reset so one user's +session data never leaks to the next user on the same client token: + +- Protected base vars revert to their declared defaults. +- Protected cached computed vars are dropped. +- Backend vars are cleared. + +**Public (`auth=False`) fields and vars are preserved** across logout — they are +not part of the authenticated session. + +## Exempt states + +Some state classes are never protected and never gated: + +- State classes defined inside `reflex` or `reflex_enterprise`. +- Any `OIDCAuthState` subclass — i.e. the auth providers, even user-defined ones. + +This is why a provider state's own vars are always delivered (they read straight +from the auth cookies, so they are simply empty until you log in), and why the +page guard — itself a framework state — can resolve the user without being gated. + +## Reading the current user + +Import the `User` facade to read the current user from either the frontend or +the backend: + +```python +from reflex_enterprise.auth import User +``` + +**Frontend Vars** — embed these class-level descriptors directly in components. +They resolve against the *first* configured provider and are typed `str | None` +(`undefined` on the frontend): + +| Attribute | Value | +| --- | --- | +| `User.name` | The user's name claim. | +| `User.email` | The user's email claim. | +| `User.sub` | The user's subject identifier. | +| `User.picture` | The user's picture URL. | +| `User.State` | The active provider class (the first configured provider). Use it to reach provider events / `get_login_button`. | + +```python +rx.avatar(src=User.picture, fallback="U", size="5") +rx.heading(User.name, size="6") +rx.text(User.email, color_scheme="gray") +``` + +**Backend** — call these inside an event handler. Both are async: + +| Call | Returns | +| --- | --- | +| `await User.current()` | The current user's `OIDCUserInfo` dict for this event, or `None` when anonymous/unresolved. | +| `await User.current_provider()` | The provider **class** that actually resolved this event's user (vs `User.State`, which is always the first configured), or `None`. | + +`OIDCUserInfo` is a plain dict at runtime, so read claims with `.get(...)`: + +```python +class DemoState(rx.State): + @rxe.event # default auth=True + async def protected_action(self): + """Greet the logged-in user, resolved from the backend userinfo.""" + user = await User.current() or {} + return rx.toast(f"Hello {user.get('name') or user.get('sub')}!") +``` + +```md alert warning +# One function, one auth value +The same function cannot back two surfaces with different `auth` values (e.g. one var `auth=True` and another `auth=False` sharing a single getter) — that raises `ValueError`. Reusing a function with the *same* auth is fine; otherwise define a separate function per surface. +``` + +## Related + +- [Overview](/docs/enterprise/auth/overview/) — enable the plugin and read the current user. +- [Providers](/docs/enterprise/auth/providers/) — swap in a real identity provider. +- [Custom pages](/docs/enterprise/auth/custom-pages/) — replace the login / callback / logout screens. +- [Testing](/docs/enterprise/auth/testing/) — drive guarded surfaces in unit tests. +- [Reflex Enterprise overview](/docs/enterprise/overview/) — the rest of reflex-enterprise. diff --git a/docs/enterprise/auth/testing.md b/docs/enterprise/auth/testing.md new file mode 100644 index 00000000000..0ed43270000 --- /dev/null +++ b/docs/enterprise/auth/testing.md @@ -0,0 +1,232 @@ +--- +title: Testing Guarded Code +--- + +_New in reflex-enterprise v0.9.1._ + +# Testing Guarded Code + +When the [AuthPlugin](/docs/enterprise/auth/overview/) is enabled, every +non-exempt page, event handler, base field, and computed var is +[secure by default](/docs/enterprise/auth/secure-by-default/) — guarded +surfaces only resolve against a logged-in user. Unit-testing that logic would +normally require a live identity provider, a real OIDC round-trip, and browser +cookies. + +`auth_as` removes that requirement. It injects a fake authenticated user into +the per-event context that the gate populates, so guarded handlers, fields, and +vars can be exercised with no network and no IdP. Because it sets the *same* +context the gate sets, your tests run the production read path rather than a +test-only shortcut. + +```md alert info +# Tests are async +The current-user read path is async. Write the tests as `async def test_...` +and `await User.current()`. The examples below use the `pytest-asyncio` style. +``` + +## auth_as + +Import it from `reflex_enterprise.auth` (it is also available at +`reflex_enterprise.auth.testing.auth_as`): + +```python +from reflex_enterprise.auth import auth_as +``` + +`auth_as` is a context manager: + +```python +@contextlib.contextmanager +def auth_as( + userinfo: OIDCUserInfo | None, + provider: type[OIDCAuthState] | None = None, +) -> Iterator[OIDCUserInfo | None]: ... +``` + +| Argument | Type | Default | Meaning | +| --- | --- | --- | --- | +| `userinfo` | `OIDCUserInfo \| None` | — | The claims to present as the current user. Pass `None` to simulate an anonymous request. | +| `provider` | `type[OIDCAuthState] \| None` | `None` | The provider class to present as having resolved the user — what `User.current_provider()` returns inside the block. | + +Within the `with` block, the resolution path, the state-delta filtering, and +`User.current()` all see `userinfo` as the resolved current user. `auth_as(None)` +simulates an anonymous caller. On exit, the context is restored to its previous +value, so blocks can be nested and tests stay isolated. + +The `userinfo` you pass is just a [`OIDCUserInfo`](/docs/enterprise/auth/providers/) +dict — read claims with `.get(...)`. A representative value: + +```python +{ + "sub": "user-1", + "name": "Ada Lovelace", + "email": "ada@example.com", + "picture": "https://example.com/ada.png", + "groups": ["moderators"], +} +``` + +## Testing the current user + +`User.current()` reads the per-event context that `auth_as` populates and +returns the injected claims verbatim, or `None` when anonymous: + +```python +from reflex_enterprise.auth import User, auth_as + + +async def test_current_returns_injected_userinfo(): + userinfo = { + "sub": "user-1", + "name": "Ada Lovelace", + "email": "ada@example.com", + "groups": ["moderators"], + } + with auth_as(userinfo): + assert await User.current() == userinfo + + +async def test_anonymous(): + with auth_as(None): + assert await User.current() is None +``` + +Pass `provider=` when the code under test calls `User.current_provider()`; it +returns exactly the provider class you supply: + +```python +from reflex_enterprise.auth.oidc.state import GenericOIDCAuthState + + +async def test_current_provider(): + userinfo = {"sub": "user-1", "groups": ["moderators"]} + with auth_as(userinfo, provider=GenericOIDCAuthState): + assert await User.current_provider() is GenericOIDCAuthState +``` + +## Testing an authorization check + +An `auth=` [check](/docs/enterprise/auth/secure-by-default/) is an +ordinary function, so the most direct test calls it with a `userinfo` dict and +asserts the boolean result. An event check has the signature +`func(handler, payload, userinfo) -> bool` and only ever runs for an +authenticated caller, so you can pass `None` for the arguments it ignores: + +```python +def _is_moderator(handler, payload, userinfo) -> bool: + return "moderators" in (userinfo.get("groups") or []) + + +def test_is_moderator_allows_member(): + member = {"sub": "user-1", "groups": ["moderators"]} + assert _is_moderator(None, None, member) is True + + +def test_is_moderator_denies_non_member(): + outsider = {"sub": "user-2", "groups": []} + assert _is_moderator(None, None, outsider) is False +``` + +To exercise the guarded handler end-to-end instead, run it inside `auth_as` so +the check sees the injected user. A member resolves the real return value; a +non-member is denied: + +```python +import reflex as rx +import reflex_enterprise as rxe +from reflex_enterprise.auth import User, auth_as + + +class DemoState(rx.State): + @rxe.event(auth=_is_moderator) + async def moderator_action(self): + user = await User.current() or {} + return rx.toast(f"Hello {user.get('name') or user.get('sub')}!") +``` + +```md alert success +# Member vs. non-member +Inside `with auth_as(member_userinfo):` the check returns truthy and the handler +runs. Inside `with auth_as(non_member_userinfo):` the check returns falsey and +the gate denies the call with the default "Action not allowed" toast — never a +login redirect. +``` + +```md alert warning +# A check never runs for an anonymous caller +`auth_as(None)` resolves to *anonymous*, which is an authentication failure — it +short-circuits to a login redirect before any check runs. To test the check +itself, always inject a `userinfo` (call the function directly, or wrap the +handler in `auth_as(userinfo)`). +``` + +## End-to-end testing against a mock IdP + +`auth_as` injects a user and skips the network — the right tool for +unit-testing guarded logic. When you instead want to exercise the **full** OIDC +flow (the login redirect, the `/callback` token exchange, JWKS validation, and +the userinfo fetch), run your app against a local mock identity provider. + +[`oidc-provider-mock`](https://pypi.org/project/oidc-provider-mock/) is a small +OIDC server that runs in-process — the same tool reflex-enterprise uses for its +own integration tests. Add it as a dev dependency: + +```bash +uv add --dev oidc-provider-mock +``` + +Run it on a background thread and point the `OIDC_*` env vars at it before the +app starts. It accepts any client credentials by default (no registration) and +issues refresh tokens, so the fixture is short: + +```python +import os + +import pytest +from oidc_provider_mock import User, run_server_in_thread + + +@pytest.fixture(scope="session") +def mock_idp(): + """Run a local mock OIDC IdP and point the OIDC_* env vars at it.""" + env = { + "AUTHLIB_INSECURE_TRANSPORT": "1", # accept plain-http localhost + "OIDC_CLIENT_ID": "test-client", + "OIDC_CLIENT_SECRET": "test-secret", + } + # Save and restore so the test run doesn't leak OIDC_* into other tests. + saved = {key: os.environ.get(key) for key in [*env, "OIDC_ISSUER_URI"]} + os.environ.update(env) + users = [ + User(sub="user-1", claims={"name": "Ada Lovelace", "groups": ["admins"]}), + ] + try: + with run_server_in_thread(user_claims=users) as server: + os.environ["OIDC_ISSUER_URI"] = f"http://localhost:{server.server_port}" + yield server + finally: + for key, value in saved.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value +``` + +Then drive the app's real `/login`, `/callback`, and `/logout` pages with your +browser-automation harness of choice, logging in as one of the users you +defined. `oidc-provider-mock` also ships a CLI if you prefer to run the IdP as a +standalone server for manual local testing. + +```md alert info +# `auth_as` first; the mock IdP when you're testing the wiring +Reach for `auth_as` in the common case — it's faster and needs no server. Use +`oidc-provider-mock` only when the OIDC wiring itself (redirect, callback, token +exchange, refresh) is what's under test, not just the guarded logic. +``` + +## Related + +- [Auth Overview](/docs/enterprise/auth/overview/) — enable the plugin and read the current user. +- [Secure by Default](/docs/enterprise/auth/secure-by-default/) — the `auth=` wrappers and enforcement semantics the tests exercise. +- [Enterprise Overview](/docs/enterprise/overview/) — the full reflex-enterprise feature set. diff --git a/docs/enterprise/overview.md b/docs/enterprise/overview.md index c43f71e1611..8e2547066e3 100644 --- a/docs/enterprise/overview.md +++ b/docs/enterprise/overview.md @@ -61,6 +61,48 @@ categories_data = [ }, ], }, + { + "category": "Authentication", + "description": "OIDC authentication with a secure-by-default model", + "count": 5, + "components": [ + { + "feature": "AuthPlugin", + "description": "OIDC (OpenID Connect) authentication with secure-by-default protection", + "cloud_tier": "Enterprise", + "self_hosted_tier": "Enterprise", + "link": "/docs/enterprise/auth/overview", + }, + { + "feature": "Secure by Default", + "description": "Pages, event handlers, fields, and computed vars require login unless opted out", + "cloud_tier": "Enterprise", + "self_hosted_tier": "Enterprise", + "link": "/docs/enterprise/auth/secure-by-default", + }, + { + "feature": "OIDC Providers", + "description": "Built-in generic OIDC provider plus named and multi-provider setups", + "cloud_tier": "Enterprise", + "self_hosted_tier": "Enterprise", + "link": "/docs/enterprise/auth/providers", + }, + { + "feature": "Custom Auth Pages", + "description": "Replace the rendered login, callback, and logout pages", + "cloud_tier": "Enterprise", + "self_hosted_tier": "Enterprise", + "link": "/docs/enterprise/auth/custom-pages", + }, + { + "feature": "Testing", + "description": "Exercise guarded surfaces with an injected user via auth_as", + "cloud_tier": "Enterprise", + "self_hosted_tier": "Enterprise", + "link": "/docs/enterprise/auth/testing", + }, + ], + }, { "category": "AGGrid and AGChart", "description": "Advanced data visualization and grid components", From 55ed1970c0f9139923180f34bdfe169f4c2081e2 Mon Sep 17 00:00:00 2001 From: Farhan Date: Tue, 23 Jun 2026 00:04:27 +0500 Subject: [PATCH 2/2] docs(auth): clarify User Vars bind to AuthUserState MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document that User.name/.email/.sub/.picture resolve against AuthUserState — populated after login by whichever provider authenticated the user — so they work in single- and multi-provider setups alike, rather than the first configured provider. Correct their type from `str | None` to `str` (empty until login) and note AuthUserState.provider_name / User.current_provider() for branching on the active provider. --- docs/enterprise/auth/overview.md | 14 ++++++-------- docs/enterprise/auth/providers.md | 6 ++++++ docs/enterprise/auth/secure-by-default.md | 5 +++-- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/docs/enterprise/auth/overview.md b/docs/enterprise/auth/overview.md index bfa4831ef9a..a79ae2789a5 100644 --- a/docs/enterprise/auth/overview.md +++ b/docs/enterprise/auth/overview.md @@ -80,11 +80,11 @@ enforcement model and check-function signatures. ## Reading the current user -`reflex_enterprise.auth.User` is a facade over the active provider for reading -the current user. Its class-level Vars (`User.name`, `User.email`, and the rest) -embed directly in components, each typed `str | None`. Inside an event handler, -`await User.current()` returns the current user's `OIDCUserInfo` dict (or `None` -when anonymous): +`reflex_enterprise.auth.User` is the app-facing handle on the current user. Its +class-level Vars — `User.name`, `User.email`, `User.sub`, `User.picture` — embed +directly in components (`rx.text(User.name)`, `rx.avatar(src=User.picture)`). +Inside an event handler, `await User.current()` returns the user's `OIDCUserInfo` +dict (or `None` when anonymous): ```python import reflex as rx @@ -99,9 +99,7 @@ class DemoState(rx.State): return rx.toast(f"Hello {user.get('name') or user.get('sub')}!") ``` -In components, render the Vars directly, e.g. `rx.text(User.name)` or -`rx.avatar(src=User.picture)`. See -[secure-by-default](/docs/enterprise/auth/secure-by-default/) for more on the +See [secure-by-default](/docs/enterprise/auth/secure-by-default/) for the full `User` facade and how protected values are delivered after login. ## Learn more diff --git a/docs/enterprise/auth/providers.md b/docs/enterprise/auth/providers.md index ad5cdfc1ade..497e5ba70c1 100644 --- a/docs/enterprise/auth/providers.md +++ b/docs/enterprise/auth/providers.md @@ -191,6 +191,12 @@ the offending providers. Set a provider-specific `{PROVIDER}_*` variable (e.g. `OKTA_ISSUER_URI`, `AZURE_ISSUER_URI`) for each. ``` +Reading the user is provider-agnostic: `User.name` / `.email` / `.sub` / +`.picture` bind to `AuthUserState`, which is populated by whichever provider +completes login, so they render correctly no matter which button the visitor +clicked. To branch on the provider, read `AuthUserState.provider_name` in a +component or `await User.current_provider()` in an event handler. + ## Customizing claims `OIDCUserInfo` is a `TypedDict` (`total=False`) with a single `sub` key; it is a diff --git a/docs/enterprise/auth/secure-by-default.md b/docs/enterprise/auth/secure-by-default.md index 137a3c53b23..20e0ce12117 100644 --- a/docs/enterprise/auth/secure-by-default.md +++ b/docs/enterprise/auth/secure-by-default.md @@ -290,8 +290,9 @@ from reflex_enterprise.auth import User ``` **Frontend Vars** — embed these class-level descriptors directly in components. -They resolve against the *first* configured provider and are typed `str | None` -(`undefined` on the frontend): +They bind to `AuthUserState`, populated after login by whichever provider +authenticated the user, so they are correct in single- and multi-provider setups +alike. Each is typed `str` (empty `""` until login): | Attribute | Value | | --- | --- |