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..a79ae2789a5 --- /dev/null +++ b/docs/enterprise/auth/overview.md @@ -0,0 +1,117 @@ +--- +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 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 +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')}!") +``` + +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 + +- [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..497e5ba70c1 --- /dev/null +++ b/docs/enterprise/auth/providers.md @@ -0,0 +1,260 @@ +--- +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. +``` + +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 +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..20e0ce12117 --- /dev/null +++ b/docs/enterprise/auth/secure-by-default.md @@ -0,0 +1,340 @@ +--- +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 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 | +| --- | --- | +| `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",