diff --git a/reboot/aio/BUILD.bazel b/reboot/aio/BUILD.bazel index 54ee332d..69de83a8 100644 --- a/reboot/aio/BUILD.bazel +++ b/reboot/aio/BUILD.bazel @@ -36,6 +36,7 @@ py_library( "//reboot:run_environments_py", "//reboot:version_py", "//reboot:versioning_py", + "//reboot/aio/auth:native_redirect_uris_py", "//reboot/aio/auth:oauth_providers_py", "//reboot/aio/auth:oauth_server_py", "//reboot/aio/auth:token_verifiers_py", diff --git a/reboot/aio/applications.py b/reboot/aio/applications.py index 8433792b..81f1f6f0 100644 --- a/reboot/aio/applications.py +++ b/reboot/aio/applications.py @@ -13,6 +13,7 @@ from mcp.server.fastmcp import FastMCP from pathlib import Path from rbt.v1alpha1.application.application_pb2 import ExamplePrompt +from reboot.aio.auth.native_redirect_uris import validate_native_redirect_uri from reboot.aio.auth.oauth_providers import OAuthProviderSelector from reboot.aio.auth.oauth_server import OAuthServer from reboot.aio.auth.token_verifiers import ( @@ -250,6 +251,7 @@ def __init__( token_verifier: Optional[TokenVerifier] = None, oauth: Optional[OAuthProviderSelector] = None, allowed_origins: Optional[list[str]] = None, + native_redirect_uris: Optional[list[str]] = None, title: Optional[str] = None, description: Optional[str] = None, example_prompts: Optional[list[ExamplePrompt]] = None, @@ -322,6 +324,49 @@ def __init__( default-None case almost always means "the developer forgot", and we'd rather raise loudly than silently CORS-block every sign-in attempt in production. + :param native_redirect_uris: exact-match list of the redirect + URIs belonging to this application's own first-party + native apps — a mobile app's custom scheme (e.g. + `"myapp://redirect"`), or an `https://` App Link / + Universal Link. A native app cannot use the browser + sign-in flow (there is no page to redirect and no cookie + jar to hold the session), so it registers itself + dynamically (RFC 7591) and completes an ordinary + authorization-code flow with PKCE instead. + + Registration proves nothing about who is registering, so + by default such a client is treated as third-party: the + user is shown a consent screen naming it, and the flow + only continues once they approve. That screen is what + stands between a user and an attacker who registers a + client with *their own* `redirect_uri`, sends the user an + `/__/oauth/authorize` link on this trusted origin, and + collects an access token for the user's identity once + they sign in. PKCE is no help there, because in that + attack the attacker is the registered client. + + Listing a redirect URI here says it is yours, so a client + that registers only such URIs signs the user in directly, + with no consent screen — the same treatment the browser + SPA gets. It is safe for exactly one reason: an + authorization code issued for one of these URIs is + delivered to *your* app, so an attacker registering the + same URI gains nothing. Entries are therefore compared for + exact equality, and wildcards are refused. + + Under `rbt dev run`, Expo's `exp:///--/...` + development URIs are trusted automatically, because they + carry the development machine's address and port and so + have no stable spelling to list here. + + Note that a custom scheme is claimed on a first-come basis + on some platforms, so a hostile app on the same device can + register `myapp://` too. PKCE contains that: the code it + intercepts is useless without the verifier, which never + leaves your app. An `https://` App Link / Universal Link, + which the operating system verifies against your domain, + avoids the race entirely and is the stronger choice where + you can use one. :param title: a human-readable name for the application. Defaults to `application_name()` if unset. :param description: a human-readable description of the @@ -525,6 +570,25 @@ def __init__( "never carry them, so an entry with a path would " "never match." ) + # Only meaningful alongside an OAuth server; without one there + # is no registration for the list to classify, so a lone + # `native_redirect_uris` is a config mistake worth surfacing + # rather than silently ignoring. + if native_redirect_uris is not None and oauth is None: + raise InputError( + reason=( + "`Application(native_redirect_uris=...)` requires " + "`oauth=...`: it marks which OAuth clients are " + "your own first-party native apps, and without an " + "OAuth provider this application has no OAuth " + "clients." + ), + ) + self._native_redirect_uris: list[str] = list( + native_redirect_uris or [] + ) + for redirect_uri in self._native_redirect_uris: + validate_native_redirect_uri(redirect_uri) self._title = title or application_name() self._description = description self._example_prompts = example_prompts or [] @@ -780,6 +844,7 @@ def _mount_oauth( authenticated=self._authenticated, claims_changed=self._set_claims_if_exists, allowed_origins=self._allowed_origins, + native_redirect_uris=self._native_redirect_uris, ) self._oauth_server = oauth_server if self._token_verifier is not None: diff --git a/reboot/aio/auth/BUILD.bazel b/reboot/aio/auth/BUILD.bazel index e7488efe..c37f6121 100644 --- a/reboot/aio/auth/BUILD.bazel +++ b/reboot/aio/auth/BUILD.bazel @@ -60,6 +60,16 @@ py_library( ], ) +py_library( + name = "native_redirect_uris_py", + srcs = ["native_redirect_uris.py"], + srcs_version = "PY3", + visibility = ["//visibility:public"], + deps = [ + "//reboot:run_environments_py", + ], +) + py_library( name = "oauth_providers_py", srcs = ["oauth_providers.py"], @@ -90,6 +100,7 @@ py_library( deps = [ ":__init___py", ":allowed_origins_py", + ":native_redirect_uris_py", ":oauth_providers_py", ":token_verifiers_py", "//reboot:settings_py", @@ -109,6 +120,7 @@ py_library( ":__init___py", ":admin_auth_py", ":authorizers_py", + ":native_redirect_uris_py", ":oauth_providers_py", ":oauth_server_py", ":token_verifiers_py", diff --git a/reboot/aio/auth/native_redirect_uris.py b/reboot/aio/auth/native_redirect_uris.py new file mode 100644 index 00000000..8ded9c11 --- /dev/null +++ b/reboot/aio/auth/native_redirect_uris.py @@ -0,0 +1,96 @@ +"""The set of native redirect URIs an application claims as its own. + +A mobile or desktop app signs in through the same OAuth authorization +server as an MCP client does, registering itself dynamically (RFC 7591) +and receiving the authorization code at a redirect URI of its own — +typically a custom scheme like `myapp://redirect`. Nothing about that +registration proves who registered, so by default the user is asked to +vouch for the client on the consent screen before the flow continues. + +Listing a redirect URI here is the application developer stating that +it belongs to their own first-party app, which lets sign-in skip that +question. +""" + +import re +from reboot.run_environments import running_rbt_dev +from typing import Sequence + +# Full-string regexes for the native redirect URIs that are trusted +# automatically under `rbt dev run`. Expo (React Native's toolchain) +# serves a project from the development machine, so the redirect URI it +# hands the app carries that machine's address and a port — both of +# which change with the machine, the network, and the run. There is no +# stable string for a developer to put in +# `Application(native_redirect_uris=...)`, so we match the shape +# instead, and only in local development. +# +# Deliberately specific to Expo's scheme rather than covering localhost +# the way `allowed_origins` does in development. MCP clients register +# localhost redirect URIs, so trusting localhost here would stop them +# from showing the consent screen under `rbt dev run` — and a developer +# who never sees it locally is one who meets it for the first time in +# production. +DEV_REDIRECT_URI_REGEXES = (r"exp://[^/]+/--(/.*)?",) + +# URI schemes never accepted, whatever the allow-list says: each one +# executes or reads local content rather than naming an app to hand an +# authorization code to. +_FORBIDDEN_SCHEMES = frozenset(["javascript", "data", "vbscript", "file"]) + +# A URI scheme per RFC 3986: a letter followed by letters, digits, and +# `+`, `-`, or `.`. +_SCHEME_REGEX = r"[a-zA-Z][a-zA-Z0-9+.\-]*" + + +def validate_native_redirect_uri(redirect_uri: object) -> None: + """Raise `ValueError` if `redirect_uri` is not usable as an entry of + `Application(native_redirect_uris=...)`.""" + if not isinstance(redirect_uri, str): + raise ValueError( + "`native_redirect_uris` must be a list of strings; got " + f"entry of type {type(redirect_uri).__name__}" + ) + match = re.match(f"({_SCHEME_REGEX}):", redirect_uri) + if match is None: + raise ValueError( + f"`native_redirect_uris` entry {redirect_uri!r} must be a " + "full URI beginning with a scheme, e.g. " + "'myapp://redirect' for a custom-scheme app link or " + "'https://app.example.com/redirect' for a verified " + "App Link / Universal Link" + ) + scheme = match.group(1).lower() + if scheme in _FORBIDDEN_SCHEMES: + raise ValueError( + f"`native_redirect_uris` entry {redirect_uri!r} uses the " + f"forbidden '{scheme}' scheme" + ) + if "*" in redirect_uri: + raise ValueError( + f"`native_redirect_uris` entry {redirect_uri!r} must not " + "contain a wildcard: entries are compared for exact " + "equality against the `redirect_uri` a client registers, " + "because that URI is where an authorization code for one " + "of your users is delivered" + ) + + +def is_first_party_redirect_uri( + redirect_uri: str, + *, + native_redirect_uris: Sequence[str], +) -> bool: + """Whether `redirect_uri` belongs to one of the application's own + first-party native apps: an exact match against the explicit + allow-list `native_redirect_uris`, or — under `rbt dev run` — a + development redirect URI whose shape only a local toolchain + produces.""" + if redirect_uri in native_redirect_uris: + return True + if running_rbt_dev(): + return any( + re.fullmatch(regex, redirect_uri) is not None + for regex in DEV_REDIRECT_URI_REGEXES + ) + return False diff --git a/reboot/aio/auth/oauth_server.py b/reboot/aio/auth/oauth_server.py index 19f8eafd..c86596d9 100644 --- a/reboot/aio/auth/oauth_server.py +++ b/reboot/aio/auth/oauth_server.py @@ -25,6 +25,7 @@ Auth, ) from reboot.aio.auth.allowed_origins import is_allowed_origin +from reboot.aio.auth.native_redirect_uris import is_first_party_redirect_uri from reboot.aio.auth.oauth_providers import ( ClaimsChanged, OAuthProvider, @@ -136,6 +137,19 @@ def signing_secret() -> bytes: ) +def _bearer_token(request: Request) -> Optional[str]: + """The token from `request`'s `Authorization: Bearer ` + header, or `None` when there is no such header or it carries some + other scheme.""" + header = request.headers.get("authorization") + if header is None: + return None + scheme, _, token = header.partition(" ") + if scheme.lower() != "bearer" or not token: + return None + return token + + def _compute_code_challenge(code_verifier: str) -> str: """The PKCE S256 code challenge for `code_verifier` (RFC 7636 4.2): the URL-safe, unpadded base64 of its SHA-256 digest.""" @@ -325,6 +339,7 @@ def __init__( Awaitable[None]]] = None, claims_changed: Optional[ClaimsChanged] = None, allowed_origins: Optional[Sequence[str]] = None, + native_redirect_uris: Optional[Sequence[str]] = None, ): """`authenticated`, if given, runs right after each fresh access token is minted for a user, receiving an app-internal @@ -347,11 +362,20 @@ def __init__( explicit allow-list (with `None` meaning it, like `oauth=`, was never set); browser-flow redirect targets are validated against the same trusted-origins set Envoy's CORS uses. + + `native_redirect_uris` is + `Application(native_redirect_uris=...)`'s allow-list of the + redirect URIs belonging to the application's own first-party + native apps; a client registering only such URIs skips the + consent screen. """ self._provider = provider self._protected_resources = protected_resources self._application_title = application_title self._allowed_origins: list[str] = list(allowed_origins or []) + self._native_redirect_uris: list[str] = list( + native_redirect_uris or [] + ) self._auto_construct_state_type_full_names: list[str] = list( auto_construct_state_type_full_names or [] ) @@ -892,6 +916,23 @@ async def register(self, request: Request) -> JSONResponse: "type": "client", "redirect_uris": redirect_uris, } + # A client every one of whose redirect URIs the application + # claims as its own (`Application(native_redirect_uris=...)`) + # is a first-party native app, and `/authorize` signs its user + # in without a consent screen. Every URI must qualify: one + # unclaimed entry is enough for an authorization code to reach + # somebody else, and the client chooses per-request which of + # its registered URIs to use. The marker rides inside the + # signed `client_id`, so it is as unforgeable as the + # `redirect_uris` beside it, and it is recomputed on each + # registration rather than trusted from the request. + if all( + isinstance(redirect_uri, str) and is_first_party_redirect_uri( + redirect_uri, + native_redirect_uris=self._native_redirect_uris, + ) for redirect_uri in redirect_uris + ): + client_metadata["first_party"] = True # RFC 7591 client metadata we surface on the consent screen so a # user can recognize who's asking. Optional and # attacker-controlled (anyone can register), so they're shown @@ -994,14 +1035,23 @@ async def authorize(self, request: Request): mcp_state = params.get("state", "") - # The first-party browser client (minted by `/__/oauth/start`) - # skips the consent screen: it's server-minted with a fixed, - # same-origin `redirect_uri`, so the confused-deputy attack the + # Two kinds of client skip the consent screen, both of them the + # application's own. The browser client minted by + # `/__/oauth/start` is server-minted with a fixed, same-origin + # `redirect_uri`. A native client is one whose every registered + # redirect URI the application claimed via + # `Application(native_redirect_uris=...)`, checked at + # registration and carried in the signed `client_id`. Either + # way the authorization code can only land somewhere the + # application already trusts, so the confused-deputy attack the # consent screen guards against — an attacker registering a # client with their own `redirect_uri` — can't arise. Go # straight to the browser-flow behavior: reuse an existing # session if there is one, otherwise sign in at the IdP. - if client_data.get("type") == _BROWSER_CLIENT_TYPE: + if ( + client_data.get("type") == _BROWSER_CLIENT_TYPE or + client_data.get("first_party") is True + ): existing_user_id = self._verify_session_cookie(request) if existing_user_id is not None: return self._redirect_with_auth_code( @@ -1852,14 +1902,22 @@ async def signout(self, request: Request) -> Response: async def whoami(self, request: Request) -> JSONResponse: """GET /__/oauth/whoami - Lightweight initial-load probe for the SPA. Returns + Lightweight initial-load probe for a front end. Returns `{authenticated: true, user_id, access_token, default_ids}` - when `rbt_session` is present and valid, `{authenticated: + when the caller presents a valid session, `{authenticated: false}` otherwise. The `default_ids` map carries `{state_type_full_name: state_id}` for every auto-construct state type — same shape MCP delivers via tool results, so - generated React hooks can resolve without the SPA threading - the user_id through every call. + generated React hooks can resolve without the front end + threading the user_id through every call. + + The session comes from the `rbt_session` cookie, or, failing + that, from an `Authorization: Bearer` access JWT. The bearer + form is what a native app uses: it holds its access token + directly (it signed in through the authorization-code flow + rather than the browser one, and has no cookie jar shared with + the backend), and this is how it learns its `default_ids` + without hardcoding which state types are auto-constructed. Why a server round-trip exists at all, rather than the SPA just reading the cookie from JavaScript: Reboot serves the @@ -1883,16 +1941,22 @@ async def whoami(self, request: Request) -> JSONResponse: the response a JWT-exfiltration vector for any origin we let read it credentialed. That's the load-bearing reason `Application(allowed_origins=...)` is an exact-match - allow-list rather than a wildcard. + allow-list rather than a wildcard. The bearer branch adds + nothing to that exposure: it echoes back only the very token + the caller already had to present to reach it. """ - decoded = self._decode_session_cookie(request) + # The cookie's value IS the access JWT, so either way the + # token we verify is the one we hand back — the front end's + # bearer and its session then expire in lockstep. + access_token = request.cookies.get(SESSION_COOKIE_NAME) + if access_token is None: + access_token = _bearer_token(request) + if access_token is None: + return JSONResponse({"authenticated": False}) + decoded = self._verify_jwt(access_token, "access") user_id = decoded.get("sub", "") if decoded is not None else "" if decoded is None or not user_id: return JSONResponse({"authenticated": False}) - # The cookie's value IS the access JWT — surface it inline - # so the SPA's bearer and the cookie expire in lockstep - # (the SPA's refresh path renews both at once). - access_token = request.cookies.get(SESSION_COOKIE_NAME) # `expires_at` lets the SPA's `RebootClientProvider` # schedule a `refreshBearer(...)` a few seconds before # the access JWT expires, so unary RPCs / WebSocket diff --git a/reboot/examples/bank-pydantic/README.md b/reboot/examples/bank-pydantic/README.md index 55dc73d7..e874a99d 100644 --- a/reboot/examples/bank-pydantic/README.md +++ b/reboot/examples/bank-pydantic/README.md @@ -100,9 +100,21 @@ pick (or make up) a `Development` identity. Signing in auto-constructs your `User`, which signs you up as a customer of the bank (see `backend/src/user_servicer.py`), so the web app shows only your own accounts. The web app and the MCP surface share sign-on: signing in on -one signs you in on the other. The mobile app does not sign in: the -browser-redirect OAuth flow (and its cookie-backed session) is not -available on native React Native. +one signs you in on the other. + +The mobile app signs in too, against the same OAuth server, and with +the same `useSignIn()` / `useSignOut()` / `useUser()` hooks. It cannot +use the browser-redirect flow the web app uses — React Native has no +page to redirect and no cookie jar to hold the session — so it passes +`nativeAuth({...})` from `@reboot-dev/reboot-react/native` to its +`RebootClientProvider`, and Reboot runs the standard +authorization-code flow with PKCE that native apps use instead, +keeping the resulting tokens in the device keychain. Because +`backend/src/main.py` claims the app's redirect URI through +`Application(native_redirect_uris=...)`, Reboot recognizes it as a +first-party app and signs the user straight in, with no consent +screen — the same treatment the web app gets. A client that registers +some *other* redirect URI still gets one. #### MCP diff --git a/reboot/examples/bank-pydantic/backend/src/main.py b/reboot/examples/bank-pydantic/backend/src/main.py index 70a385dd..d01089d6 100644 --- a/reboot/examples/bank-pydantic/backend/src/main.py +++ b/reboot/examples/bank-pydantic/backend/src/main.py @@ -37,6 +37,15 @@ async def main(): # to start until one is chosen. prod=None, ), + # The redirect URI of our own mobile app (see + # `frontend/mobile/`), which tells Reboot that a client + # registering it is first-party and can sign a user in + # directly. Without this the mobile app would be treated like + # any other dynamically registered client and its users would + # have to approve a consent screen first. Expo's development + # redirect URI is trusted automatically under `rbt dev run`, + # so this entry is what a standalone build needs. + native_redirect_uris=["bankpydanticmobile://redirect"], # Include `SortedMap` library. libraries=[sorted_map_library()], initialize=initialize, diff --git a/reboot/examples/bank-pydantic/frontend/mobile/.maestro/flow.yaml b/reboot/examples/bank-pydantic/frontend/mobile/.maestro/flow.yaml index 55f2c6b7..ab4d12ae 100644 --- a/reboot/examples/bank-pydantic/frontend/mobile/.maestro/flow.yaml +++ b/reboot/examples/bank-pydantic/frontend/mobile/.maestro/flow.yaml @@ -6,17 +6,20 @@ # so this flow does not `launchApp`; it drives the foreground app. # `appId` is Expo Go's package because the app runs inside it. # -# What it proves: two customers are created, an account is opened for -# each, and a transfer between them round-trips through the backend so -# the reactive balances re-render — i.e. the Reboot client's native -# reads and mutations actually work. Assertions target elements by -# `testID` (see `App.tsx`), not arbitrary on-screen text. +# What it proves: the native OAuth sign-in completes and yields a +# usable session, the signed-in user opens two accounts, and a transfer +# between them round-trips through the backend so the reactive balances +# re-render — i.e. the Reboot client's native reads and mutations +# actually work, carrying the bearer that the OAuth flow produced. +# Assertions target elements by `testID` (see `App.tsx`), not arbitrary +# on-screen text, except inside the sign-in browser tab, which the app +# does not render and so cannot label. appId: host.exp.exponent --- # Wait for the app to finish mounting before driving it. - extendedWaitUntil: visible: - id: "customer-id-input" + id: "sign-in-button" timeout: 30000 # On a fresh Expo Go install, the first project open shows Expo Go's @@ -59,17 +62,42 @@ appId: host.exp.exponent timeout: 10000 - waitForAnimationToEnd -# The app loads with an empty bank. +# The app loads signed out. - assertVisible: "Rebank" -# Create two customers. The input and button are in the first section, -# so no scrolling is needed yet. Crucially, wait for each customer to -# register before creating the next: `signUp` clears the input -# asynchronously, so without the wait the next `inputText` appends onto -# the previous value (e.g. "alicebob") and the second customer is never -# created. A customer's chip appearing in the "Add Account" picker below -# confirms its `signUp` landed (and the input has cleared). +# Sign in. Tapping "Sign in" hands off to an in-app browser tab on the +# backend's OAuth server, so the next steps drive Chrome rather than +# the app: the tab belongs to a different package, but Maestro matches +# against whatever is on screen, so plain text selectors reach it. # +# The tab goes straight to the `Development` provider's fake account +# picker, with no consent screen on the way: `main.py` claims this +# app's redirect URI through `Application(native_redirect_uris=...)`, +# and Expo Go's development redirect URI is trusted automatically +# under `rbt dev run`. The picker is served by the backend, not the +# app, hence no testIDs to target. +- tapOn: + id: "sign-in-button" + +# Pick a fixed identity so the run is reproducible. The user id behind +# it is derived from the app's root keys, so it is not predictable +# here — only the display name is. Chrome's first run can put its own +# onboarding in front of the page, so wait generously. +- extendedWaitUntil: + visible: "Alice" + timeout: 60000 +- tapOn: "Alice" + +# The redirect closes the tab and returns to the app, which exchanges +# the authorization code for tokens and re-renders signed in. +# Auto-construction of the user's `User` signs them up as a bank +# customer along the way, so the signed-in view is ready to use. +- extendedWaitUntil: + visible: + id: "signed-in-as" + timeout: 60000 + +# Open the signed-in user's first account with a $1000 initial deposit. # To dismiss the soft keyboard after typing we tap the field's `Label` # (a plain, non-pressable `Text`) rather than `hideKeyboard`. On Android # `hideKeyboard` issues a Back press, and in Expo Go a Back press pops @@ -81,41 +109,6 @@ appId: host.exp.exponent # keyboard finishes sliding away before the next tap: that same # dismissal otherwise races and swallows the very next tap meant for a # button, flakily dropping the mutation it would have fired. -- tapOn: - id: "customer-id-input" -- inputText: "alice" -- tapOn: - id: "customer-id-label" -- waitForAnimationToEnd -- tapOn: - id: "create-customer-button" -- extendedWaitUntil: - visible: - id: "open-account-customer-alice" - timeout: 30000 -- tapOn: - id: "customer-id-input" -- inputText: "bob" -- tapOn: - id: "customer-id-label" -- waitForAnimationToEnd -- tapOn: - id: "create-customer-button" -- extendedWaitUntil: - visible: - id: "open-account-customer-bob" - timeout: 30000 - -# Open an account for alice with a $1000 initial deposit. The customer -# chips are a reactive read of `useAllCustomerIds`, so they appear once -# `signUp` lands; `scrollUntilVisible` doubles as "wait for + scroll to" -# the chip. -- scrollUntilVisible: - element: - id: "open-account-customer-alice" - direction: DOWN -- tapOn: - id: "open-account-customer-alice" - tapOn: id: "initial-deposit-input" - inputText: "1000" @@ -123,15 +116,24 @@ appId: host.exp.exponent id: "initial-deposit-label" - waitForAnimationToEnd - tapOn: - id: "add-account-button" + id: "open-account-button" -# Open an account for bob with a $500 initial deposit. +# Wait for the account to land before opening the next one: the +# transfer pickers are a reactive read of `useBalances`, and each +# account's chip appears once its `openAccount` has landed. The chips +# are addressed by the position of their account in the user's list, so +# `transfer-from-0` appearing confirms the first account exists. - scrollUntilVisible: element: - id: "open-account-customer-bob" + id: "transfer-from-0" direction: DOWN -- tapOn: - id: "open-account-customer-bob" + +# Open a second account with a $500 initial deposit, so there is +# somewhere to transfer to. +- scrollUntilVisible: + element: + id: "initial-deposit-input" + direction: UP - tapOn: id: "initial-deposit-input" - inputText: "500" @@ -139,25 +141,23 @@ appId: host.exp.exponent id: "initial-deposit-label" - waitForAnimationToEnd - tapOn: - id: "add-account-button" - -# Transfer $100 from alice's account to bob's. Each test customer has a -# single account, so the chips are addressable by owning customer -# (`transfer-from-` / `transfer-to-`), with no -# dependence on chip order. `scrollUntilVisible` brings each control -# into view before tapping it; the form is taller than the viewport. + id: "open-account-button" - scrollUntilVisible: element: - id: "transfer-from-alice" + id: "transfer-from-1" direction: DOWN + +# Transfer $100 from the first account to the second. +# `scrollUntilVisible` brings each control into view before tapping it; +# the form is taller than the viewport. - tapOn: - id: "transfer-from-alice" + id: "transfer-from-0" - scrollUntilVisible: element: - id: "transfer-to-bob" + id: "transfer-to-1" direction: DOWN - tapOn: - id: "transfer-to-bob" + id: "transfer-to-1" - scrollUntilVisible: element: id: "transfer-amount-input" @@ -178,15 +178,16 @@ appId: host.exp.exponent # The reactive balances re-render. Accounts also accrue $1 of interest # every few seconds (see `account_servicer.py`), so the exact amount # drifts upward — assert the hundreds range, not an exact value. After -# the transfer alice is in the $900s ($1000 - $100) and bob in the -# $600s ($500 + $100); neither was in those ranges beforehand, so this -# also confirms the transfer landed. +# the transfer the first account is in the $900s ($1000 - $100) and the +# second in the $600s ($500 + $100); neither was in those ranges +# beforehand, so this also confirms the transfer landed. - scrollUntilVisible: element: id: "account-balance" text: "\\$9\\d\\d" direction: DOWN -# bob's account row is below alice's in the table, so scroll to it too. +# The second account's row is below the first in the table, so scroll +# to it too. - scrollUntilVisible: element: id: "account-balance" diff --git a/reboot/examples/bank-pydantic/frontend/mobile/.tests/maestro_test.sh b/reboot/examples/bank-pydantic/frontend/mobile/.tests/maestro_test.sh index 99ece40c..e65b5715 100755 --- a/reboot/examples/bank-pydantic/frontend/mobile/.tests/maestro_test.sh +++ b/reboot/examples/bank-pydantic/frontend/mobile/.tests/maestro_test.sh @@ -177,6 +177,18 @@ until [ "$(adb shell getprop sys.boot_completed 2> /dev/null | tr -d '\r')" = "1 sleep 1 done +# Skip Chrome's first-run screen. Signing in hands the OAuth +# authorization URL to an in-app browser tab, which Chrome serves; on +# an AVD where Chrome has never been opened, it answers that intent +# with its `FirstRunActivity` ("Welcome to Chrome") rather than +# loading the URL, and stays there — so the flow would wait for a page +# that never renders. Chrome reads this file only for the app marked +# as the debug app, hence both commands; the leading `chrome` stands +# in for the program name, which it discards. +chrome_flags="chrome --disable-fre --no-first-run --no-default-browser-check" +adb shell "echo '${chrome_flags}' > /data/local/tmp/chrome-command-line" +adb shell am set-debug-app --persistent com.android.chrome + # Load the app via Expo Go, pointed at the backend on the emulator's # host alias (`10.0.2.2`). `expo start --android` installs Expo Go and # opens the project. diff --git a/reboot/examples/bank-pydantic/frontend/mobile/README.md b/reboot/examples/bank-pydantic/frontend/mobile/README.md index 8ac96afb..b56b38a2 100644 --- a/reboot/examples/bank-pydantic/frontend/mobile/README.md +++ b/reboot/examples/bank-pydantic/frontend/mobile/README.md @@ -8,11 +8,68 @@ Native — on iOS, Android, and the web — in addition to the browser. The UI is the browser example ported to React Native primitives (`View`, `Text`, `TextInput`, `Pressable`, `ScrollView`); the Reboot -integration (`RebootClientProvider`, the generated `useBank` hook, -reactive `useAccountBalances`/`useAllCustomerIds`, and optimistic -`signUp`/`openCustomerAccount`/`transfer`) is identical to `frontend/web/`. The -browser's ``. +integration (`RebootClientProvider`, the generated `useUser` hook, +reactive `useBalances`, and optimistic `openAccount`/`transfer`) is +identical to `frontend/web/`. The browser's ``. + +## Signing in + +Like the web front end, this app requires signing in, and it reaches +the same OAuth server with the same `useSignIn()`, `useSignOut()`, and +generated `useUser()` hooks. What differs is only how the sign-in +itself runs: the browser-redirect flow needs a `window.location` to +redirect and a cookie jar to hold the session, neither of which React +Native has. So `App.tsx` hands `RebootClientProvider` a +`nativeAuth({...})` from `@reboot-dev/reboot-react/native`: + +```tsx +const auth = expoAuth({ WebBrowser, SecureStore, Linking }); + + +``` + +Reboot then runs the standard authorization-code flow with PKCE that +native apps use — discovery, client registration, PKCE, the token +exchange, and refreshing the access token before it expires — and +everything above `RebootClientProvider` is written exactly as it is +for the web. + +The three modules passed in are the things React Native has no +standard answer for: a browser to run the flow in +([`expo-web-browser`](https://docs.expo.dev/versions/latest/sdk/webbrowser/)), +the device keychain to keep the refresh token in +([`expo-secure-store`](https://docs.expo.dev/versions/latest/sdk/securestore/)), +and the URL builder that turns `app.json`'s `scheme` into the redirect +URI ([`expo-linking`](https://docs.expo.dev/versions/latest/sdk/linking/)). +Passing them rather than having Reboot import them keeps +`@reboot-dev/reboot-react` free of any dependency on a particular +React Native toolchain — a bare React Native app supplies its own +equivalents to `nativeAuth` instead — and lets this app's own +type-checker confirm its installed Expo version matches what Reboot +expects. + +`expoAuth` also handles the two things the web bundle of this app +needs: `expo-secure-store` doesn't exist there, so the session falls +back to `sessionStorage`, and the OAuth flow runs in a popup that has +to hand its result back to the window that opened it. + +The app registers itself with the OAuth server dynamically, and the +backend recognizes it as first-party because +`backend/src/main.py` claims its redirect URI through +`Application(native_redirect_uris=[...])` — so the user goes straight +to the identity provider with no consent screen in between. Expo Go's +`exp://` development redirect URI is trusted automatically under `rbt +dev run`, so a `npm start` / `npm run ios` / `npm run android` run +needs no configuration. + +Running the app in a browser (`npm run web`) is the exception: its +redirect URI is an ordinary `http://localhost:/redirect`, which +is indistinguishable from the redirect URI an MCP client registers, so +Reboot does not trust it by shape and the sign-in shows a consent +screen. That is a quirk of running a mobile app in a browser, not of +the mobile flow; the real web front end is `frontend/web/`. ## React Native compatibility diff --git a/reboot/examples/bank-pydantic/frontend/mobile/app.json b/reboot/examples/bank-pydantic/frontend/mobile/app.json index e55b2858..d684a6d7 100644 --- a/reboot/examples/bank-pydantic/frontend/mobile/app.json +++ b/reboot/examples/bank-pydantic/frontend/mobile/app.json @@ -5,6 +5,7 @@ "version": "0.1.0", "orientation": "portrait", "userInterfaceStyle": "light", + "scheme": "bankpydanticmobile", "newArchEnabled": true, "ios": { "supportsTablet": true @@ -14,6 +15,7 @@ }, "web": { "bundler": "metro" - } + }, + "plugins": ["expo-web-browser", "expo-secure-store"] } } diff --git a/reboot/examples/bank-pydantic/frontend/mobile/package.json b/reboot/examples/bank-pydantic/frontend/mobile/package.json index fddcb380..91e33617 100644 --- a/reboot/examples/bank-pydantic/frontend/mobile/package.json +++ b/reboot/examples/bank-pydantic/frontend/mobile/package.json @@ -15,7 +15,10 @@ "@bufbuild/protobuf": "1.10.1", "@reboot-dev/reboot-react": "1.4.0", "expo": "^54.0.1", + "expo-linking": "~8.0.12", + "expo-secure-store": "~15.0.8", "expo-status-bar": "~3.0.8", + "expo-web-browser": "~15.0.11", "react": "19.1.0", "react-dom": "19.1.0", "react-native": "0.81.5", diff --git a/reboot/examples/bank-pydantic/frontend/mobile/src/App.tsx b/reboot/examples/bank-pydantic/frontend/mobile/src/App.tsx index 54990f56..45d63533 100644 --- a/reboot/examples/bank-pydantic/frontend/mobile/src/App.tsx +++ b/reboot/examples/bank-pydantic/frontend/mobile/src/App.tsx @@ -1,5 +1,13 @@ -import { RebootClientProvider } from "@reboot-dev/reboot-react"; +import { + RebootClientProvider, + useSignIn, + useSignOut, +} from "@reboot-dev/reboot-react"; +import { expoAuth } from "@reboot-dev/reboot-react/native"; +import * as Linking from "expo-linking"; +import * as SecureStore from "expo-secure-store"; import { StatusBar } from "expo-status-bar"; +import * as WebBrowser from "expo-web-browser"; import { type ReactNode, useState } from "react"; import { Platform, @@ -11,7 +19,8 @@ import { View, } from "react-native"; import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context"; -import { useBank, type UseBankApi } from "../../api/bank/v1/bank_rbt_react"; +import { useBank } from "../../api/bank/v1/bank_rbt_react"; +import { useUser, type UseUserApi } from "../../api/bank/v1/user_rbt_react"; // Identifier for the shared singleton bank state instance. const STATE_MACHINE_ID = "reboot-bank"; @@ -23,12 +32,34 @@ const STATE_MACHINE_ID = "reboot-bank"; const REBOOT_URL = process.env.EXPO_PUBLIC_REBOOT_URL ?? "http://localhost:9991"; +// Native sign-in. Reboot runs the OAuth flow itself; these three are +// the pieces React Native has no standard answer for — a browser to +// run it in, the device keychain to keep the session in, and the +// scheme-aware URL builder that says where to come back to. So +// `useSignIn()`, `useSignOut()` and `useUser()` below behave exactly +// as they do in `frontend/web/`. +// +// `Linking` derives the redirect URI from the `scheme` in `app.json`, +// and `backend/src/main.py` lists that URI in +// `Application(native_redirect_uris=[...])` — which is what lets +// Reboot sign users in with no consent screen. +// +// Built once at module scope, not inline in the JSX below: +// `RebootClientProvider` rebuilds its session machinery whenever this +// value changes identity. +const auth = expoAuth({ + WebBrowser, + SecureStore, + Linking, + clientName: "Rebank Mobile", +}); + // A wrapping row of selectable "chips". React Native has no `