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/9] 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/9] 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 | | --- | --- | From fbaebd75135b94eb74e17c6da12d1eb755dfc4aa Mon Sep 17 00:00:00 2001 From: Farhan Date: Thu, 25 Jun 2026 21:47:12 +0500 Subject: [PATCH 3/9] docs(auth): add production deployment guide and expand auth pages Add a new "Deploying to Production" page covering the HTTPS/Secure-cookie requirement, exact redirect URI construction, reverse-proxy origin handling, and a troubleshooting reference keyed on literal errors. Wire it into the enterprise sidebar, overview listing, and docs whitelist. Revise the existing auth pages (overview, secure-by-default, providers, custom-pages, testing) for accuracy and depth. --- .../sidebar/sidebar_items/enterprise.py | 4 + docs/app/reflex_docs/whitelist.py | 6 +- docs/enterprise/auth/custom-pages.md | 178 +++++-- docs/enterprise/auth/deployment.md | 134 +++++ docs/enterprise/auth/overview.md | 202 ++++++-- docs/enterprise/auth/providers.md | 357 +++++++++---- docs/enterprise/auth/secure-by-default.md | 478 +++++++++++++----- docs/enterprise/auth/testing.md | 218 ++++---- docs/enterprise/overview.md | 11 +- 9 files changed, 1159 insertions(+), 429 deletions(-) create mode 100644 docs/enterprise/auth/deployment.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 6e833f7bce5..538d5265622 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 @@ -57,6 +57,10 @@ def get_sidebar_items_enterprise_usage(): names="Testing Guarded Code", link=enterprise.auth.testing.path, ), + SideBarItem( + names="Deploying to Production", + link=enterprise.auth.deployment.path, + ), ], ), ] diff --git a/docs/app/reflex_docs/whitelist.py b/docs/app/reflex_docs/whitelist.py index ffe91bfe749..735e51d4f95 100644 --- a/docs/app/reflex_docs/whitelist.py +++ b/docs/app/reflex_docs/whitelist.py @@ -11,8 +11,10 @@ """ WHITELISTED_PAGES = [ - # Auth docs preview — matches all 5 pages under /enterprise/auth/ by prefix, - # plus the enterprise overview landing page so navigation into the section works. + # Auth docs preview — the "/enterprise/auth" prefix matches all 6 pages under + # /enterprise/auth/ (overview, secure-by-default, providers, custom-pages, + # testing, deployment), 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 index b95ab78a5b6..a1f64c3821e 100644 --- a/docs/enterprise/auth/custom-pages.md +++ b/docs/enterprise/auth/custom-pages.md @@ -6,24 +6,24 @@ _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. +`rxe.AuthPlugin` registers four friendly routes and owns their **wiring**. You +can replace the **rendered component** on each route, but the real OIDC flow +itself — the login button's redirect (via `get_login_button`), the callback's +`on_load` token exchange, and the logout's `on_load` dispatch — stays +plugin-owned, so you never reimplement it. + +| Endpoint | Default route | Plugin-owned wiring | Builder | +| --- | --- | --- | --- | +| `login_endpoint` | `/login` | Renders the login palette / starts the OIDC redirect. | `login_page` | +| `auth_callback_endpoint` | `/callback` | CSRF (OAuth `state`) check + authorization-code token exchange, then redirect back. | `callback_page` | +| `logout_endpoint` | `/logout` | Dispatches the active provider's logout. | `logout_page` | +| `forbidden_endpoint` | `/forbidden` | Shown when an authenticated user lacks permission to view a page. | `forbidden_page` | 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. +`logout_endpoint`, `auth_callback_endpoint`, and `forbidden_endpoint`. 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 @@ -50,15 +50,15 @@ 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. +applies to the login, callback, logout, and forbidden 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): +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 (the provider's +`__provider__` title-cased by default): ```python import reflex as rx @@ -67,32 +67,46 @@ import reflex as rx def custom_login_page(providers, **context) -> rx.Component: return rx.center( rx.vstack( + rx.heading("Sign in"), *[ provider.get_login_button( rx.button(f"Continue with {provider.display_name()}") ) for provider in providers ], + spacing="4", ), + min_height="60vh", ) ``` With two or more providers this naturally renders one button per provider — a login palette where the visitor picks an identity provider. +Wrapping `provider.get_login_button()` also preserves the iframe/popup message +listener it mounts — see "Running inside an iframe" in +[providers](/docs/enterprise/auth/providers/). + ## 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: +logout) proceeds — and an error view if it fails (see +[auth-failure UX and troubleshooting](#auth-failure-ux-and-troubleshooting)): ```python import reflex as rx def custom_callback_page(providers, **context) -> rx.Component: - return providers[0].get_authentication_loading_page() + return rx.center( + rx.vstack( + rx.text("Completing sign-in…"), + providers[0].get_authentication_loading_page(), + ), + min_height="60vh", + ) def custom_logout_page(providers, **context) -> rx.Component: @@ -102,17 +116,104 @@ def custom_logout_page(providers, **context) -> rx.Component: 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 +## Auth-failure UX and troubleshooting -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: +When a token exchange or validation fails, `get_authentication_loading_page()` +swaps its spinner for an error view: a user-facing message plus an **error ID** +(a per-flow UUID) the user can hand to support. The same failure is logged on the +backend at `ERROR` level, prefixed ` [txid=]` — emitted even +when the app configures no logging — so the ID the user sees greps straight to +the matching server log. + +```md alert info +# Operator note +When a user reports a failed login, search the backend logs for `[txid=...]` with the ID they were shown. +``` + +The page builders do **not** take an error override — `default_callback_page` +just calls `get_authentication_loading_page()`. There are two real ways to +customize the failure UI: + +**1. Override the state classmethods.** Subclass your provider state and override +`get_error_component`, `get_authentication_error_component`, or +`get_logout_error_component`. The loading page picks up the override automatically: ```python import reflex as rx +from reflex_enterprise.auth import GenericOIDCAuthState + + +class MyProviderState(GenericOIDCAuthState): + @classmethod + def get_error_component(cls, operation, suggestion, error_id) -> rx.Component: + return rx.vstack( + rx.heading("Something went wrong"), + rx.text(suggestion), + rx.text("Error ID: ", rx.badge(error_id)), + ) +``` + +**2. Hand-write a page reading the public vars.** `has_error`, +`user_error_message`, and `last_error_txid` are public Vars on the provider state, +so a custom callback or logout builder can branch on them directly: +```python +import reflex as rx + + +def custom_callback_page(providers, **context) -> rx.Component: + provider = providers[0] + return rx.center( + rx.cond( + provider.has_error, + rx.vstack( + rx.heading("Sign-in failed"), + rx.text(provider.user_error_message), + rx.text("Error ID: ", rx.badge(provider.last_error_txid)), + ), + provider.get_authentication_loading_page(), + ), + min_height="60vh", + ) +``` + +## A custom forbidden page + +`/forbidden` is shown when an **authenticated** user tries to load a page they +aren't authorized to view — i.e. the global default `AuthPlugin(auth=...)` is a +callable check that the user fails on a page load. It's a normal page with no +plugin-owned `on_load`, so it's the most freely customizable: + +```python +import reflex as rx + + +def custom_forbidden_page(**context) -> rx.Component: + return rx.center( + rx.vstack( + rx.heading("403", size="8"), + rx.text("You don't have access to that page."), + rx.link("Back to home", href="/"), + spacing="3", + align="center", + ), + min_height="60vh", + ) +``` + +```md alert info +# When does the forbidden page appear? +Only on a **page** load that an authenticated user fails (a callable global default). Failed event-handler checks show an `"Action not allowed"` toast, and failed field/var checks simply withhold the value — neither navigates to `/forbidden`. See [authentication vs authorization](/docs/enterprise/auth/secure-by-default/#authentication-vs-authorization). +``` + +## 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_enterprise as rxe config = rxe.Config( @@ -122,6 +223,7 @@ config = rxe.Config( 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", + forbidden_page="my_app.auth_pages.custom_forbidden_page", ), ], ) @@ -129,7 +231,7 @@ config = rxe.Config( ```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`. +The import-path string is only required because of the rxconfig re-entry. Where the builder is already importable, you can pass the callable directly: `login_page=custom_login_page`. ``` ## Defaults @@ -142,14 +244,14 @@ Omit a builder and the plugin falls back to its defaults from | `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()`. | +| `forbidden_page` | `default_forbidden_page` | A 403 "you don't have permission" view. | -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: +The defaults take the same 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 +from reflex_enterprise.auth import default_login_page def custom_login_page(providers, **context) -> rx.Component: @@ -158,8 +260,6 @@ def custom_login_page(providers, **context) -> rx.Component: ## Related -- [Providers](/docs/enterprise/auth/providers/) — configure the identity - providers the login page renders buttons for. +- [Providers](/docs/enterprise/auth/providers/) — configure the identity providers the login page renders buttons for. +- [Secure by default](/docs/enterprise/auth/secure-by-default/) — how the rest of the app is protected, and when `/forbidden` is shown. - [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/deployment.md b/docs/enterprise/auth/deployment.md new file mode 100644 index 00000000000..d0b707de0ac --- /dev/null +++ b/docs/enterprise/auth/deployment.md @@ -0,0 +1,134 @@ +--- +title: Deploying to Production +--- + +_New in reflex-enterprise v0.9.1._ + +# Deploying to Production + +The [quickstart](/docs/enterprise/auth/overview/) works on `localhost` out of +the box, but moving the same app to a real deployment against a real identity +provider (IdP) introduces a few hard requirements: the app must be served over +HTTPS, the callback URL you register at the IdP must match exactly, and behind a +reverse proxy the public origin must be reported correctly. This page covers +each requirement and ends with a troubleshooting reference keyed on the literal +errors you'll see. + +## HTTPS is required + +All four auth cookies — access token, ID token, refresh token, and granted +scopes — are issued `Secure; SameSite=Strict; HttpOnly`. These attributes are +fixed and **not configurable**. Because browsers drop `Secure` cookies sent over +plain HTTP, the app **must** be served to the browser over HTTPS in any +non-localhost deployment. Local `reflex run` works without TLS only because +browsers exempt `localhost` from the `Secure` rule. + +```md alert warning +# Symptom: an apparent login loop +If the app is served over plain HTTP on a non-localhost host, the IdP redirect to `/callback` completes, but the browser silently discards the `Secure` auth cookies. The user is never authenticated and is bounced straight back to `/login`. The root cause is the dropped cookie, **not** a misconfigured IdP — serve the app over HTTPS to fix it. +``` + +Behind a TLS-terminating reverse proxy, terminate TLS at the proxy and ensure +the browser-facing origin is `https`. The browser — not the backend — enforces +`Secure`, so the connection the browser sees is what matters. + +## Registering the callback URL + +The OAuth redirect URI is built at runtime from the browser-visible page URL, +with the path replaced by the plugin's `auth_callback_endpoint` (`/callback` by +default). It is **not** a config value: its scheme, host, and port follow the +request origin. This is the concrete version of step 3 in the +[overview](/docs/enterprise/auth/overview/). + +Register the **full** callback URL — scheme, host, port, and path — as an +allowed redirect URI in your IdP's client settings, and register every origin +you actually use: + +```bash +http://localhost:3000/callback # dev +https://your-app.com/callback # prod +``` + +A redirect URI that doesn't exactly match a registered value produces the IdP's +most common error, `redirect_uri_mismatch`. + +```md alert info +# Behind a TLS-terminating proxy +Make sure the app perceives the request as `https` (correct forwarded-proto handling at the proxy). Otherwise it builds an `http://...` redirect URI that won't match the `https://...` value you registered, and the IdP rejects it. +``` + +## Behind a reverse proxy / at scale + +Two properties of the auth model determine how it behaves behind a proxy or +across multiple replicas. + +**The public origin must be correct.** Both the redirect URI and the post-login +index URI are derived from the browser-visible request origin, so a reverse +proxy must forward the real public scheme and host. If the proxy reports +`http` or an internal hostname, the runtime redirect URI won't match what's +registered at the IdP. The backend (`api_url`) must also be reachable from the +browser: after login the client makes a credentialed request to +`/_reflex/cookies/sync` to persist the auth cookies. + +**No sticky sessions are needed.** Auth secrets live entirely in client-side +HTTP cookies (a LocalStorage flag signals other tabs to re-pull the cookies) — +there is no server-side session store. You can therefore run multiple backend replicas +behind a load balancer with no sticky-session configuration; just give every +replica the same OIDC client configuration (the same `OIDC_*` / `{PROVIDER}_*` +environment variables). + +## Connecting a real identity provider + +Every provider is discovered from its issuer's +`.well-known/openid-configuration`, always runs the Authorization Code flow with +PKCE (`S256`), and requests `openid email profile` by default. The +`client_secret` is optional, so each IdP client can be either **public** (PKCE +only, no secret) or **confidential** (PKCE plus a secret). The mechanics are the +same for every IdP — only the issuer URL changes. + +A worked example with Google: + +1. In the Google Cloud console, create an **OAuth 2.0 Client ID** of type **Web + application**. +2. Add your callback URLs (see + [registering the callback URL](#registering-the-callback-url)) as **Authorized + redirect URIs**, e.g. `https://your-app.com/callback`. +3. Configure the app. Google issues a client secret, so run the client as + confidential by setting all three variables (omit `OIDC_CLIENT_SECRET` to run + PKCE-public instead): + +```bash +OIDC_ISSUER_URI=https://accounts.google.com +OIDC_CLIENT_ID=your-client-id.apps.googleusercontent.com +OIDC_CLIENT_SECRET=your-client-secret +``` + +4. To receive group claims for authorization checks, add the relevant scope via + `extra_scopes` (see + [providers](/docs/enterprise/auth/providers/#scopes-and-refresh-tokens)). + +Other IdPs follow the same pattern with their own issuer URL: + +| IdP | `OIDC_ISSUER_URI` form | +| --- | --- | +| Google | `https://accounts.google.com` | +| Azure AD | `https://login.microsoftonline.com//v2.0` | +| Auth0 | `https://.auth0.com` | +| Okta | `https://.okta.com` | + +## Troubleshooting + +| Symptom | Cause | Fix | +| --- | --- | --- | +| `redirect_uri_mismatch` (from the IdP) | The redirect URI is derived from the request scheme + host + `auth_callback_endpoint`; the value registered at the IdP doesn't match it exactly. | Register the exact callback URL; behind a proxy, make sure the app sees the public scheme and host. | +| `invalid_client` (from the IdP at token exchange) | The IdP client is confidential, but `OIDC_CLIENT_SECRET` is unset — it silently defaults to empty, so there's no local error. | Set the client secret, or switch the IdP client to public / PKCE. | +| Logged in but bounced back to `/login` (login loop) | `Secure; SameSite=Strict` cookies are dropped over plain HTTP on a non-localhost host. | Serve the app over HTTPS (`localhost` is exempt). | +| `ConfigError` at startup | Either a plain `rx.App()` was used with the plugin, or two or more providers both fall back to the shared `OIDC_*` config for issuer or client id. | Use `rxe.App()`; for multiple providers, set provider-specific `{PROVIDER}_*` variables (see [providers](/docs/enterprise/auth/providers/#multiple-providers)). | + +## Related + +- [Authentication overview](/docs/enterprise/auth/overview/) — the quickstart and the end-to-end login flow. +- [Secure by default](/docs/enterprise/auth/secure-by-default/) — the enforcement model and authorization checks. +- [Providers](/docs/enterprise/auth/providers/) — provider config, environment variables, scopes, and multi-provider setups. +- [Custom pages](/docs/enterprise/auth/custom-pages/) — replace the login / callback / logout / forbidden page builders. +- [Testing](/docs/enterprise/auth/testing/) — exercise guarded surfaces and the full OIDC flow against a mock IdP. diff --git a/docs/enterprise/auth/overview.md b/docs/enterprise/auth/overview.md index a79ae2789a5..14ce623ae5f 100644 --- a/docs/enterprise/auth/overview.md +++ b/docs/enterprise/auth/overview.md @@ -6,25 +6,29 @@ _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. +`rxe.AuthPlugin` adds [OIDC](https://openid.net/developers/how-connect-works/) +(OpenID Connect) authentication to your Reflex app with a **secure-by-default** +model. Drop the plugin into `rxe.Config(plugins=[...])`, point it at an identity +provider with three environment variables, and every page, event handler, base +field, and computed var in your app requires a logged-in user — until you +explicitly opt a surface out. + +The plugin runs the real OIDC Authorization Code + PKCE flow against your +identity provider (IdP) and auto-registers friendly `/login`, `/logout`, +`/callback`, and `/forbidden` routes, so you never reimplement the protocol. ```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. +The auth plugin ships with `reflex-enterprise` (v0.9.1+). Your app **must** use `rxe.App()` (not `rx.App()`), and you must configure an OIDC identity provider via environment variables. Using a plain `rx.App()` with the plugin raises a `ConfigError` at startup. ``` ## Quickstart -Add `rxe.AuthPlugin()` to the `plugins` list of `rxe.Config` in `rxconfig.py`, -and configure your OIDC provider through the `OIDC_*` environment variables: +There are three steps: install the plugin, configure a provider, and use +`rxe.App()`. + +**1. Add `rxe.AuthPlugin()` to `rxconfig.py`** and configure your OIDC provider +through the `OIDC_*` environment variables: ```python import os @@ -34,7 +38,7 @@ 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") +os.environ.setdefault("OIDC_CLIENT_SECRET", "your-client-secret") # optional with PKCE config = rxe.Config( app_name="my_app", @@ -44,7 +48,7 @@ config = rxe.Config( ) ``` -Your app must use `rxe.App()` (not `rx.App()`): +**2. Use `rxe.App()`** (not `rx.App()`) in your app module: ```python import reflex_enterprise as rxe @@ -52,39 +56,82 @@ 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 +**3. Register the redirect URI with your IdP.** Add the plugin's +`auth_callback_endpoint` (`/callback` by default) as an allowed redirect URI in +your identity provider's client settings. Register the **full** URL (scheme + +host + path) for each environment — the most common setup error is +`redirect_uri_mismatch`; see +[deploying to production](/docs/enterprise/auth/deployment/) for the exact value +to register. + +That's it. With the `OIDC_*` variables set you need **no provider code** — the +plugin defaults to a built-in `GenericOIDCAuthState` that reads those three +variables. 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: +Once the plugin is active, four kinds of surface are protected by default. Each +has its own opt-out and its own behavior when a caller is not allowed: + +| Surface | Default | How it's withheld | Opt out / gate | +| --- | --- | --- | --- | +| Pages (`@rxe.page` / `@rx.page` / `app.add_page`) | login required | redirect to `/login` | `auth=False`, or `@rxe.page(auth=True)` to force login | +| Event handlers (`@rxe.event`) | login required | block + redirect/toast | `@rxe.event(auth=False)` or `auth=` | +| Base fields (`rxe.field` / plain `rx.field`) | withheld until login | replaced with its declared default | `rxe.field(default, auth=False)` or `auth=` | +| Computed vars (`@rxe.var`) | withheld until login | replaced with its `initial_value` (dropped if it has none) | `@rxe.var(auth=False)` or `auth=` | + +`auth=True` is the secure default on every surface, so a plain `rx.field(...)` or +a bare `@rxe.var` on one of your state classes is **already** protected. You +don't mark things protected — they start protected, and you open up the +surfaces that should be public. + +```python +import reflex as rx +import reflex_enterprise as rxe + + +class DashboardState(rx.State): + # Protected by default — withheld from the client until the user logs in. + revenue: rx.Field[float] = rx.field(0.0) -| 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)` | + # Explicitly public — always sent to the client. + theme: rx.Field[str] = rxe.field("light", 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. + @rxe.event # default auth=True: anonymous callers are redirected to /login + async def refresh(self): ... + + @rxe.event(auth=False) # public handler anyone may call + def toggle_theme(self): + self.theme = "dark" if self.theme == "light" else "light" +``` + +See [secure by default](/docs/enterprise/auth/secure-by-default/) for the full +enforcement model, the four `auth=` wrappers, and authorization check functions. ## Reading the current user -`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): +`reflex_enterprise.auth.User` is the app-facing handle on the current user (an +alias of `AuthUserState`). Its class-level Vars embed directly in components and +are populated after login by whichever provider authenticated the user: + +```python +import reflex as rx +from reflex_enterprise.auth import User + + +def profile() -> rx.Component: + return rx.hstack( + rx.avatar(src=User.picture, fallback="U"), + rx.vstack( + rx.heading(User.name), + rx.text(User.email, color_scheme="gray"), + ), + ) +``` + +Inside an event handler, `await User.current()` returns the user's +`OIDCUserInfo` claims dict (or `None` when anonymous): ```python import reflex as rx @@ -92,26 +139,83 @@ import reflex_enterprise as rxe from reflex_enterprise.auth import User -class DemoState(rx.State): +class GreetState(rx.State): @rxe.event # default auth=True - async def protected_action(self): + async def greet(self): user = await User.current() or {} return rx.toast(f"Hello {user.get('name') or user.get('sub')}!") ``` -See [secure-by-default](/docs/enterprise/auth/secure-by-default/) for the full -`User` facade and how protected values are delivered after login. +The full `User` facade — frontend Vars, `current()`, and `current_provider()` — +is documented in +[secure by default](/docs/enterprise/auth/secure-by-default/#reading-the-current-user). + +## Signing out + +There is no logout-button helper. Sign the user out either by linking to the +`/logout` route — which dispatches the active provider's logout — or by binding +the provider's `redirect_to_logout` event directly: + +```python +import reflex as rx +from reflex_enterprise.auth import GenericOIDCAuthState + +# Either link to the logout route… +rx.link("Sign out", href="/logout") + +# …or bind the provider's logout event. +rx.button("Sign out", on_click=GenericOIDCAuthState.redirect_to_logout) +``` + +A canonical header that links to `/login` when anonymous and to `/logout` once a +user is signed in, completing the `User.provider_name` pattern from +[secure by default](/docs/enterprise/auth/secure-by-default/#reading-the-current-user): + +```python +import reflex as rx +from reflex_enterprise.auth import User + + +def header() -> rx.Component: + return rx.cond( + User.provider_name != "", + rx.hstack( + rx.text(f"Signed in as {User.name}"), + rx.link("Sign out", href="/logout"), + ), + rx.link("Log in", href="/login"), + ) +``` + +## How a login flows end to end + +1. An anonymous visitor hits a protected page (or calls a protected handler) and + is redirected to `/login`, with the page they wanted preserved as a + `redirect_to` query parameter. +2. `/login` renders a button per configured provider. The visitor clicks one and + is sent to the IdP's authorization endpoint (Authorization Code + PKCE). +3. The IdP authenticates the user and redirects back to `/callback`, which + validates the OAuth `state` (CSRF), exchanges the code for tokens, and stores + them in secure cookies. +4. The user is redirected back to `redirect_to`. Protected fields, vars, pages, + and handlers now resolve against the authenticated user. +5. `/logout` clears the session — tokens and the protected surface of every + state — and chains the provider's logout. ## 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. + enforcement model, the four `auth=` wrappers, sync and async authorization + checks, and the `User` facade. +- [Providers](/docs/enterprise/auth/providers/) — the built-in + `GenericOIDCAuthState`, naming your own provider, OIDC environment variables, + scopes and refresh tokens, and multi-provider setups. - [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`. + `/login`, `/callback`, `/logout`, and `/forbidden` components with your own + builders. +- [Testing](/docs/enterprise/auth/testing/) — unit-testing authorization checks + and exercising the full OIDC flow against a mock IdP. +- [Deploying to production](/docs/enterprise/auth/deployment/) — HTTPS/cookies, + the exact redirect URI, reverse proxies, and a troubleshooting reference. - [Enterprise overview](/docs/enterprise/overview/) — the rest of reflex-enterprise. diff --git a/docs/enterprise/auth/providers.md b/docs/enterprise/auth/providers.md index 497e5ba70c1..06bf7c59120 100644 --- a/docs/enterprise/auth/providers.md +++ b/docs/enterprise/auth/providers.md @@ -9,13 +9,14 @@ _New in reflex-enterprise v0.9.1._ 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. +variables, so the common case needs no provider code at all. -See [secure-by-default](/docs/enterprise/auth/secure-by-default/) for how the +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, the claims they return, 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. @@ -27,11 +28,11 @@ variables: ```bash OIDC_ISSUER_URI=https://your-issuer.example.com OIDC_CLIENT_ID=your-client-id -OIDC_CLIENT_SECRET=your-client-secret +OIDC_CLIENT_SECRET=your-client-secret # optional — PKCE works without it ``` `AuthPlugin.auth_providers` defaults to `[GenericOIDCAuthState]`, so with the -`OIDC_*` variables set you do not need to write or register a custom provider: +`OIDC_*` variables set you don't write or register a provider: ```python import reflex as rx @@ -43,29 +44,18 @@ config = rxe.Config( ) ``` -`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 -``` +Register the plugin's `auth_callback_endpoint` (default `/callback`) as the +redirect URI with your IdP. ## Naming a provider -To use provider-specific environment variables (and to register multiple -distinct IdPs), subclass `OIDCAuthState` and set `__provider__`: +To use provider-specific environment variables (and to register multiple distinct +IdPs), subclass `OIDCAuthState` and set `__provider__`. The subclass must also +inherit `rx.State`: ```python import reflex as rx -from reflex_enterprise.auth.oidc.state import OIDCAuthState +from reflex_enterprise.auth import OIDCAuthState class OktaAuthState(OIDCAuthState, rx.State): @@ -73,7 +63,7 @@ class OktaAuthState(OIDCAuthState, rx.State): ``` With `__provider__ = "okta"`, config resolution prefers the -`OKTA_CLIENT_ID` / `OKTA_CLIENT_SECRET` / `OKTA_ISSUER_URI` variables, falling +`OKTA_ISSUER_URI` / `OKTA_CLIENT_ID` / `OKTA_CLIENT_SECRET` variables, falling back to the shared `OIDC_*` keys: ```bash @@ -82,30 +72,63 @@ 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): +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() + return ( + OktaAuthState.get_login_button() + ) # or .get_login_button(rx.button("Sign in with Okta")) ``` +`display_name()` returns a pretty label for the provider — by default the +`__provider__` value title-cased (`"okta"` → `"Okta"`). Override it to control +the login-button label: + +```python +class OktaAuthState(OIDCAuthState, rx.State): + __provider__ = "okta" + + @classmethod + def display_name(cls) -> str: + return "Okta SSO" +``` + +## Running inside an iframe + +When the app detects it is embedded in an iframe, login and logout automatically +switch from a top-level redirect to a popup window with a `postMessage` token +hand-off — so a Reflex auth app embeds inside another product with no extra +configuration. The popup opens from the user's click (to avoid pop-up blockers) +and posts tokens back to the app's own origin. + +This auto-switch only works through the provider's `get_login_button()` — it +mounts the message listener that receives the tokens posted from the popup. A +hand-rolled button that merely calls `redirect_to_login` still takes the popup +branch but has nowhere to receive the tokens, so the session never completes. +If you wrap `get_login_button()` (see +[custom pages](/docs/enterprise/auth/custom-pages/)), keep the returned element +in the tree so the listener stays mounted. + +Advanced override knobs live on your provider subclass: `_use_popup_flow(self) +-> bool` decides whether the popup flow is used (override to force or disable +it; the default returns whether the app is iframed). + ## 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. +`{PROVIDER}_{KEY}` variable first, then falling back to the shared `OIDC_{KEY}`. +`{PROVIDER}` is the uppercased `__provider__`. | Key | Provider-specific | Shared fallback | Notes | | --- | --- | --- | --- | -| Issuer | `{PROVIDER}_ISSUER_URI` | `OIDC_ISSUER_URI` | The IdP issuer URL. | +| Issuer | `{PROVIDER}_ISSUER_URI` | `OIDC_ISSUER_URI` | The IdP issuer URL (its `.well-known/openid-configuration` is discovered from here). | | 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 @@ -116,10 +139,7 @@ Order is preserved, and the two forms may be mixed. The default is ```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. +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 resolves them lazily once the config exists. ``` In `rxconfig.py`, pass strings: @@ -137,7 +157,7 @@ config = rxe.Config( ) ``` -Outside `rxconfig.py` (for example, in tests), you may pass the classes +Outside `rxconfig.py` (for example, in tests) you may pass the classes themselves: ```python @@ -149,8 +169,8 @@ 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`): +scopes each one requests. The merge is deduped and preserves the existing scopes +(`openid email profile` by default): ```python rxe.AuthPlugin( @@ -160,10 +180,56 @@ rxe.AuthPlugin( ``` - `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. + Once granted, the framework refreshes the access token automatically and + proactively as it nears expiry, coordinated across browser tabs. The refresh + request only asks for the scopes the IdP originally **granted**, so a scope the + IdP consumed without granting (e.g. `offline_access` itself) won't cause the + refresh to fail with `invalid_scope`. +- Without `offline_access` there is no refresh token, so the session ends when + the access token expires and the user is returned to `/login`. +- If a refresh fails (the refresh token was revoked or expired), the session is + reset and the user is logged out. - `extra_scopes=["groups"]` requests group claims, useful for authorization - checks against `userinfo.get("groups")`. + checks against `ctx.auth_user_state.userinfo.get("groups")`. + +Independent of token expiry, every auth cookie has a fixed 7-day lifetime (no +configuration knob) — this is the effective maximum session length. See the +[deployment guide](/docs/enterprise/auth/deployment/) for the HTTPS and cookie +requirements. + +### Per-provider scopes + +`extra_scopes` is applied uniformly to every configured provider and only +**adds** to the defaults. To give a single provider a distinct set — e.g. a +resource/API scope that only one IdP should receive — set the +`_requested_scopes` class attribute on that subclass. Unlike `extra_scopes`, +this **replaces** the default `"openid email profile"` rather than merging into +it: + +```python +import reflex as rx +from reflex_enterprise.auth import OIDCAuthState + + +class DatabricksAuthState(OIDCAuthState, rx.State): + __provider__ = "databricks" + _requested_scopes: str = "all-apis offline_access openid email profile" +``` + +### Reading granted scopes + +Every provider exposes a `granted_scopes` Var — the space-delimited scopes the +IdP actually granted (which may differ from what was requested). Use it to gate +optional-scope features at runtime, e.g. only show refresh-dependent UI when +`offline_access` was granted: + +```python +rx.cond( + DatabricksAuthState.granted_scopes.contains("offline_access"), + rx.text("Long-lived session enabled"), + rx.text("Session ends at token expiry"), +) +``` ## Multiple providers @@ -184,30 +250,32 @@ rxe.AuthPlugin( ```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. +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 startup naming 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. +clicked. To branch on the provider, read `User.provider_name` in a component or +`await User.current_provider()` in an event handler: + +```python +rx.cond(User.provider_name == "okta", rx.text("via Okta"), rx.text("via Azure")) +``` + +## The claims a provider returns -## Customizing claims +`OIDCUserInfo` is a `TypedDict` (`total=False`) declaring only `sub` — per the +OIDC spec it is the one claim always returned. Profile claims like `name`, +`email`, and `picture` appear only when their scope is granted. It is a plain +dict at runtime, so you read claims with `.get(...)`. -`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: +To document the extra claims a provider returns, declare a nested +`UserInfo(OIDCUserInfo, total=False)` on your provider — exactly as the built-in +`GenericOIDCAuthState` does: ```python -from reflex_enterprise.auth.oidc.state import OIDCAuthState -from reflex_enterprise.auth.oidc.types import OIDCUserInfo +from reflex_enterprise.auth import OIDCAuthState, OIDCUserInfo class OktaAuthState(OIDCAuthState, rx.State): @@ -216,45 +284,158 @@ class OktaAuthState(OIDCAuthState, rx.State): class UserInfo(OIDCUserInfo, total=False): name: str email: str + picture: str groups: list[str] ``` +The common claims are projected as read-only Vars on `User` +(`User.name`, `.email`, `.sub`, `.picture`); any other claim is read from the +dict via `await User.current()` or `ctx.auth_user_state.userinfo.get(...)`. + ## 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. +`OIDCAuthState` exposes a set of overridable async hooks for advanced cases. Most +apps never need these — the defaults run the standard flow. Override the ones you +need on your provider subclass: + +| Hook | Purpose | +| --- | --- | +| `_validate_tokens(self) -> bool` | Validate the current access and ID tokens; return whether they're valid. | +| `_verify_jwt(self, token_json) -> Token` | Verify the ID token JWT; override to customize verification. | +| `_valid_issuers(self) -> list[str] \| None` | Acceptable `iss` claim values; override for e.g. Azure multi-tenant. | +| `_set_tokens(self, access_token, id_token=None, refresh_token=None, granted_scopes=None, **kwargs)` | Persist tokens after exchange; override to handle extra response data. | +| `_set_tokens_payload_from_exchange(self, exchange) -> dict` | Build the kwargs passed to `_set_tokens`; override to forward an extra field from the token-exchange response. | +| `_validate_auth_callback_exchange(self, exchange) -> dict \| None` | Validate the token-exchange response from the callback. | +| `_fetch_userinfo(self) -> OIDCUserInfo` | Fetch claims from the IdP's userinfo endpoint; override to fetch or reshape claims. | +| `_redirect_to_login_payload(self) -> dict` | Build the authorization-request query params (scope, state, PKCE challenge); override for non-standard login params. | +| `_redirect_to_logout_payload(self) -> dict[str, str]` | Build the IdP end-session params (`state`, `id_token_hint`, `post_logout_redirect_uri`); override for a custom `post_logout_redirect_uri` or non-standard end-session. | +| `_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. | + +```md alert info +# Logout behavior +Logout chains the provider's discovered `end_session_endpoint` with an +`id_token_hint` and `post_logout_redirect_uri`. If the IdP advertises **no** +`end_session_endpoint`, logout only clears the local tokens and redirects to the +app index — the IdP session is **not** terminated, so a later login may silently +re-authenticate. +``` + +## Using the access token to call an API + +Inside an `@rx.event` (or computed var) on **your** `OIDCAuthState` subclass, +`await self._access_token` to get the current OAuth access token, then send it as +a bearer token to a downstream service or the IdP. Despite the leading +underscore, `_access_token` is the accessor — it is a server-only awaitable Var, +never exposed to the browser, and is kept fresh by the framework's background +refresh, so the value you read is normally current: + +```python +import httpx +import reflex as rx +from reflex_enterprise.auth import OIDCAuthState + + +class OktaAuthState(OIDCAuthState, rx.State): + __provider__ = "okta" + + @rx.event + async def fetch_profile(self): + access_token = await self._access_token + async with httpx.AsyncClient() as client: + resp = await client.get( + "https://api.example.com/me", + headers={"Authorization": f"Bearer {access_token}"}, + ) + resp.raise_for_status() +``` + +To react when the token is (re)issued — e.g. to invalidate a cached downstream +client — override `_on_access_token_change` or `_on_refresh_access_token` on the +subclass (see the hook table above). + +## Sharing behavior across providers + +To put the same fields, vars, event handlers, or hook overrides on more than one +provider without duplication, define an `rx.State` mixin with `mixin=True` and +list it **before** `OIDCAuthState` in each provider's bases: + +```python +import reflex as rx +from reflex_enterprise.auth import OIDCAuthState + + +class SharedAuthBehavior(rx.State, mixin=True): + async def _on_access_token_change( + self, new_access_token, refresh=False + ): ... # runs for every provider that mixes this in + + +class OktaAuthState(SharedAuthBehavior, OIDCAuthState, rx.State): + __provider__ = "okta" + + +class AzureAuthState(SharedAuthBehavior, OIDCAuthState, rx.State): + __provider__ = "azure" +``` + +Base order matters: the mixin must come before `OIDCAuthState`/`rx.State`. Each +provider still reads its own `__provider__`-namespaced cookies, so the shared +code always operates on the right provider's tokens. + +## Persisting extra token-exchange data + +`_set_tokens_payload_from_exchange` builds the payload (`access_token`, plus any +of `id_token`, `refresh_token`, and `granted_scopes` the exchange returned) from +the IdP's token-exchange response, and that payload is the entire set of kwargs +`_set_tokens` receives on both the callback and refresh paths. To persist a custom field from the +exchange, override **both** hooks — `_set_tokens_payload_from_exchange` to carry +the field through, and `_set_tokens` to accept and store it: + +```python +import reflex as rx +from reflex_enterprise.auth import OIDCAuthState + + +class OrgAuthState(OIDCAuthState, rx.State): + __provider__ = "myorg" + _org_id: str = "" + + async def _set_tokens_payload_from_exchange(self, exchange): + payload = await super()._set_tokens_payload_from_exchange(exchange) + if "org_id" in exchange: + payload["org_id"] = exchange["org_id"] + return payload + + async def _set_tokens( + self, + access_token, + id_token=None, + refresh_token=None, + granted_scopes=None, + org_id="", + **kwargs, + ): + await super()._set_tokens( + access_token, id_token, refresh_token, granted_scopes, **kwargs + ) + self._org_id = org_id +``` + +The popup/iframe login path sets tokens directly and does **not** route through +`_set_tokens_payload_from_exchange`, so always give the extra `_set_tokens` +keyword a default (as `org_id=""` above) to keep that path working. ## 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. +`rxe.Config(plugins=[...])` instead — it wires the `/login`, `/logout`, +`/callback`, and `/forbidden` 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. +- [Secure by default](/docs/enterprise/auth/secure-by-default/) — how the plugin protects every surface, and authorization checks against claims. +- [Custom pages](/docs/enterprise/auth/custom-pages/) — replace the login / callback / logout / forbidden page builders. +- [Testing](/docs/enterprise/auth/testing/) — exercise guarded surfaces and the full OIDC flow. diff --git a/docs/enterprise/auth/secure-by-default.md b/docs/enterprise/auth/secure-by-default.md index 20e0ce12117..c8302bf3338 100644 --- a/docs/enterprise/auth/secure-by-default.md +++ b/docs/enterprise/auth/secure-by-default.md @@ -6,59 +6,134 @@ _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. +Once `rxe.AuthPlugin` is configured (see the +[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: +This page covers the four `auth=` wrappers, the shared meaning of the `auth=` +value (including authorization checks), the difference between authentication +and authorization failures, and how protected values are delivered. + +```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 provider configured. See the [overview](/docs/enterprise/auth/overview/). +``` + +## The `auth=` value + +Every `rxe.*` wrapper — and the plugin-wide default — takes the same `auth=` +argument. Its 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. | +| a callable check | An authorization check that runs **only after** authentication succeeds. A 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. +`rxe.field`, and `rxe.var`. Pages take `auth` as a **bool only**; event handlers, +fields, and computed vars also accept a callable check. -```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/). +### The global default + +`AuthPlugin(auth=...)` sets the default applied to every surface that carries no +explicit `auth=` of its own. It accepts the same three values: + +```python +rxe.AuthPlugin(auth=True) # any authenticated user (the default) +rxe.AuthPlugin(auth=False) # open by default — opt in to protection per surface +rxe.AuthPlugin(auth=my_org_check) # org-wide authorization check, run after login ``` +A plain `rx.field(...)`, a bare `@rxe.var`, a bare `@rxe.event`, and an +`@rxe.page()` with no `auth=` all **inherit** this global default. An explicit +per-surface `rxe.*(auth=...)` always overrides it. This means you can flip your +whole app's baseline — from "any logged-in user" to "members of the `admins` +group" — by changing one `AuthPlugin(auth=...)` value, without touching a single +field, var, event, or page. + +### Gate the whole app behind a role + +A callable global default runs on every untagged page load, so one check gates +the entire app — no per-surface tagging: + +```python +# my_app/auth.py +from reflex_enterprise.auth import AuthContext + + +def is_member_of(ctx: AuthContext) -> bool: + """Allow only members of the ``admins`` group.""" + return "admins" in (ctx.auth_user_state.userinfo.get("groups") or []) +``` + +```python +# rxconfig.py +import reflex_enterprise as rxe + +from my_app.auth import is_member_of + +config = rxe.Config( + app_name="my_app", + plugins=[ + rxe.AuthPlugin( + auth=is_member_of, + extra_scopes=["groups"], + ) + ], +) +``` + +`extra_scopes=["groups"]` is required so the `groups` claim exists to check — +see [providers](/docs/enterprise/auth/providers/#the-claims-a-provider-returns). +Authenticated users who fail the check are sent to `/forbidden` (not back to +login); you can replace that screen with a [custom forbidden +page](/docs/enterprise/auth/custom-pages/#a-custom-forbidden-page). + ## Pages Protect a page with `@rxe.page`. For pages, `auth` is a **bool only** — callable -checks are not supported here. +checks are not supported as a per-page argument (the global default may still be +a callable; see below). ```python -@rxe.page( +def page( route: str | None = None, *, - auth: bool = True, + auth: bool | None = None, **page_kwargs, ) ``` -A protected page (`auth=True`, the default) injects -`PageGuardState.enforce_login` as the **first** `on_load` event. Anonymous +- `auth=None` (the default) — follow the configured global default + (`AuthPlugin(auth=...)`). +- `auth=True` — always require an authenticated user, regardless of the global + default. +- `auth=False` — public page (opt out). + +A protected page injects a guard 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. +- The post-login `redirect_to` target is validated against the app's origin — + only same-origin absolute paths (e.g. `/dashboard`) or URLs sharing the app's + scheme and host are honored. Any cross-origin, scheme-relative (`//evil.test`), + or backslash target silently falls back to the index page, so the framework + can't be turned into an open redirect and you don't need to add your own + validation. + ```python -@rxe.page(route="/dashboard", title="Dashboard") # auth=True is the default +@rxe.page( + route="/dashboard", title="Dashboard" +) # auth=None: follows the global default def dashboard() -> rx.Component: - """Protected page: anonymous visitors are redirected to /login.""" + """Protected: 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).""" @@ -66,22 +141,54 @@ def index() -> rx.Component: ``` 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). +`image`, `description`, `meta`, `script_tags`, and `on_load`. When you pass your +own `on_load`, the guard is prepended to it, so it always runs first. + +### Reading the user on page load + +Because the guard is prepended and runs first, by the time your own `on_load` +runs the visitor is already authenticated — so `await User.current()` (or +`ctx.auth_user_state.userinfo`) is non-`None`: + +```python +@rxe.page(route="/dashboard", title="Dashboard", on_load=DashboardState.load) +def dashboard() -> rx.Component: ... + + +class DashboardState(rx.State): + greeting: str = "" + + @rxe.event + async def load(self): + user = await User.current() or {} + self.greeting = f"Welcome, {user.get('name') or user.get('sub')}" +``` + +`User.current()` is event-only; an `on_load` handler is an event, so it resolves +the just-authenticated user. See [reading the current +user](#reading-the-current-user). ### 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 +With the plugin on, `rxe.App()` defaults **every** page to login-required — +including pages added via `app.add_page(...)` or a plain `@rx.page`. 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)`. +Plain `@rx.page` takes no `auth` argument, so to opt a decorated page out use +`@rxe.page(auth=False)` instead. + +### When the global default is a callable + +A page can't take a callable `auth=` directly, but if `AuthPlugin(auth=...)` is a +callable, untagged pages (`@rxe.page()` / `@rx.page` / `app.add_page`) run it on +load. An authenticated visitor who **fails** the check is redirected to the +`forbidden_endpoint` (`/forbidden` by default) — not back to login, which would +loop. An explicit `@rxe.page(auth=True)` only requires login and does **not** +run the callable default. ## Event handlers @@ -89,10 +196,10 @@ Protect an event handler with `@rxe.event`. Here `auth` accepts a bool or a callable check. ```python -@rxe.event( +def event( fn=None, *, - auth: bool | Callable = True, + auth: bool | EventAuthCheck = ..., **event_kwargs, ) ``` @@ -106,46 +213,31 @@ forwarded to `rx.event`: `background`, `stop_propagation`, `prevent_default`, 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 + def public_action(self): + """Anyone may call this.""" + ... ``` -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.") -``` +A failed authorization check on an event handler shows the +`"Action not allowed"` toast (see +[authentication vs authorization](#authentication-vs-authorization)). ## 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. +bare annotation — on one of your state classes is **already** protected: it is +dropped from the state delta until the user is resolved. Reach for `rxe.field` +only to opt a field out or attach a check. ```python def field( default=..., *, - auth: bool | Callable = True, + auth: bool | FieldVarAuthCheck = ..., default_factory=None, is_var=True, ) @@ -153,36 +245,27 @@ def field( ```python class DemoState(rx.State): - # Base fields are protected by default: dropped from the delta until login. + # 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) + # Explicitly public — always sent to the client. + loading: rx.Field[bool] = rxe.field(False, auth=False) ``` -A protected field is dropped from the state delta until the user is resolved, -then re-delivered (see [How withholding works](#how-withholding-works)). +A withheld field is replaced in the delta with its **declared default** (the +value baked into the frontend bundle) until the user is resolved, so the client +never holds a stale authenticated value. ## Computed vars -Computed vars are protected by default and withheld from the delta until login. -Wrap them with `@rxe.var`. +Computed vars are protected by default and withheld until login. Wrap them with +`@rxe.var`. ```python def var( fget=None, *, - auth: bool | Callable = True, + auth: bool | FieldVarAuthCheck = ..., **var_kwargs, ) ``` @@ -193,65 +276,184 @@ Extra `**var_kwargs` are forwarded verbatim to `rx.var`: `initial_value`, ```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. +A protected var is withheld until the user is resolved, so the client has no value to render in the meantime. Set `initial_value=` to a placeholder 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)") + @rxe.var(initial_value="🔒 log in to reveal this") 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." + """Protected by default; the placeholder shows until a user is resolved.""" + return "✅ 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." + """Opened up to anonymous visitors.""" + return "Anyone can read this." +``` + +## Authorization checks + +For finer-grained control than "any authenticated user," pass a **callable** as +`auth=`. A check is a function that receives a single **context object** and +returns a bool (or an awaitable of one): + +```python +def check(ctx) -> bool: ... # sync +async def check(ctx) -> bool: ... # async +``` + +A check runs **only after** authentication succeeds — an anonymous caller is +redirected to login first and never reaches the check, so a resolved user is +always present inside the check. + +### The context object + +Each surface passes a different context, all carrying the current user as +`ctx.auth_user_state` (an `AuthUserState`). Import them from +`reflex_enterprise.auth`: + +| Context | Surface | Extra attributes | +| --- | --- | --- | +| `EventAuthContext` | event handler | `event_handler` (the gated handler), `payload` (the event payload dict) | +| `VarAuthContext` | field / computed var | `field_or_var` (the `Var`, or `None`) | +| `PageAuthContext` | page (callable global default only) | — | + +Read the user's claims off `ctx.auth_user_state.userinfo`, a plain dict: + +```python +from reflex_enterprise.auth import EventAuthContext, VarAuthContext + + +def is_admin(ctx: EventAuthContext) -> bool: + """Event check: allow only members of the ``admins`` group.""" + return "admins" in (ctx.auth_user_state.userinfo.get("groups") or []) + + +def is_staff(ctx: VarAuthContext) -> bool: + """Field/var check: allow only staff.""" + return "staff" in (ctx.auth_user_state.userinfo.get("groups") or []) + + +class DemoState(rx.State): + @rxe.event(auth=is_admin) # authz failure -> "Action not allowed" toast + def admin_action(self): + return rx.toast.success("Admin action executed.") + + # Withheld unless the staff check passes; the placeholder shows otherwise. + audit_log: rx.Field[list[str]] = rxe.field([], auth=is_staff) + + @rxe.var(auth=is_staff, initial_value="🔒 staff only") + def staff_view(self) -> str: + return "✅ staff-only computed var." ``` -A var check has the signature `func(var, userinfo) -> bool`, and (as with -fields) pairs well with `initial_value`: +### One check for any surface + +Annotate `ctx` with the `AuthContext` union to make one function usable on **any** +surface; annotate it with a single context type to restrict it (and get exact +autocomplete). `isinstance(ctx, EventAuthContext)` narrows the union inside the +body: ```python +from reflex_enterprise.auth import AuthContext, EventAuthContext + + +def is_admin(ctx: AuthContext) -> bool: + """Usable as an event, field, var, or global-default check.""" + return "admins" in (ctx.auth_user_state.userinfo.get("groups") or []) +``` + +```md alert warning +# Cached claims can lag the IdP by up to 30 minutes +`ctx.auth_user_state.userinfo` is served from a server-side cache refreshed at most every 30 minutes (the access token is re-read at most about every 60 seconds), so a check on `groups` / roles can see IdP-side changes — like a group revocation — up to 30 minutes late within one token's life. The cache is invalidated whenever the access token changes, so short-lived-token setups using a refresh token (`extra_scopes=["offline_access"]`) pick up changes sooner. For immediate revocation, decide against an authoritative source inside an async check (`await ctx.auth_user_state.get_state(...)`, shown below) rather than cached claims. +``` + +### Async checks + +A check may be `async` — return an awaitable and the framework awaits it. Whether +a check ran sync or async is decided by its **result**, not by inspecting the +function, so a sync check is run inline while an async one is awaited at the +right point (the per-event gate for handlers, the delta resolution for +fields/vars). + +An async check can reach **any other state**, a database, or a remote +authorization service (e.g. OpenFGA / a ReBAC backend) via +`await ctx.auth_user_state.get_state(...)` — the round-trip a sync check can't +make: + +```python +import reflex as rx +import reflex_enterprise as rxe +from reflex_enterprise.auth import AuthContext + + +class PolicyState(rx.State): + """A sibling state (or a stand-in for a DB / authz service).""" + + admin_group: rx.Field[str] = rxe.field("admins", auth=False) + + +async def is_org_admin(ctx: AuthContext) -> bool: + """Async check combining the user's claims with a sibling state.""" + policy = await ctx.auth_user_state.get_state(PolicyState) + return policy.admin_group in (ctx.auth_user_state.userinfo.get("groups") or []) + + class DemoState(rx.State): - @rxe.var(auth=_is_admin, initial_value=0) - def pending_approvals(self) -> int: ... + @rxe.event(auth=is_org_admin) + async def privileged_action(self): + return rx.toast.success("Done.") + + @rxe.var(auth=is_org_admin, initial_value="🔒 admins only") + async def admin_view(self) -> str: + return "✅ admin-only value." +``` + +```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. ``` ## Authentication vs authorization -The two failure modes are deliberately different. Think of it as a decision tree -applied per surface against the resolved user: +The two failure modes are deliberately different. 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. | +| Not logged in (no user resolved) | **Authentication failure** — handled 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. | +| Logged in and the check returns falsey or raises | **Authorization failure** — never a login redirect. | + +What each failure does depends on the surface: -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. +| Surface | Authentication failure (anonymous) | Authorization failure (check said no) | +| --- | --- | --- | +| Event handler | block + redirect to `/login` | block + `"Action not allowed"` toast | +| Page | redirect to `/login` (with `redirect_to`) | redirect to `/forbidden` | +| Field / computed var | withheld (placeholder / default shown) | withheld (placeholder / default shown) | 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). +caller** (authentication is resolved first, so `ctx.auth_user_state` is always a +real user 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. +vars are withheld, for any caller who isn't authorized to see them. A sync check +is evaluated inline as the delta is built; an async check is deferred and awaited +during delta resolution. 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. +known, so even for a logged-in user, protected values are withheld at first — the +user simply hasn't been resolved 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 @@ -264,11 +466,24 @@ 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. +- Server-only backend vars are cleared. **Public (`auth=False`) fields and vars are preserved** across logout — they are not part of the authenticated session. +### Logout is protected against CSRF + +The plugin auto-installs middleware that blocks cross-site GET navigations to +`/logout` — using the browser-set, JS-unspoofable `Sec-Fetch-Site` header — so an +attacker's `