Add native (React Native) OAuth sign-in - #123
Conversation
Current Aviator status
This pull request is currently open (not queued). How to mergeTo merge this PR, comment
See the real-time status of this PR on the
Aviator webapp.
Use the Aviator Chrome Extension
to see the status of your PR within GitHub.
|
ced66bf to
bcbaffb
Compare
bcbaffb to
084a070
Compare
React Native can't use Reboot's browser sign-in flow: there is no `window.location` to redirect and no cookie jar shared with the backend. Teach the same OAuth server and React client to run the standard authorization-code flow with PKCE that native apps use, so a mobile app writes the same `useSignIn()` / `useSignOut()` / `useUser()` as a web SPA does. Backend: - `Application(native_redirect_uris=[...])` lets an application claim the redirect URIs of its own first-party native apps. A client that registers only such URIs signs its user in directly, as the browser client already did; every other dynamically registered client still gets the consent screen, which is what stands between a user and an attacker who registers a client with their own `redirect_uri`. Expo development URIs are trusted by shape under `rbt dev run` only — deliberately not localhost, which MCP clients also register. - `/__/oauth/whoami` additionally accepts an `Authorization: Bearer` access token, so a native app can resolve its `default_ids` without hardcoding which state types are auto-constructed. React client: - `RebootClientProvider` takes an optional `nativeAuth`, and `useSignIn()` / `useSignOut()` dispatch to it. Omitted — as on the web — everything behaves exactly as before. - A new `@reboot-dev/reboot-react/native` subpath carries the whole OAuth protocol (discovery, RFC 7591 registration, PKCE, token exchange, refresh), plus `expoAuth()` for Expo apps. It adds no dependency to `@reboot-dev/reboot-react`: the pieces React Native has no standard answer for are passed in and typed structurally, which also lets an app's own type-checker confirm its installed Expo version matches. SHA-256 is carried here rather than taken from a platform module, for the same reason. The `bank-pydantic` mobile front end now signs in, and is scoped to the signed-in user like the web front end. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
084a070 to
06a3f64
Compare
rjhuijsman
left a comment
There was a problem hiding this comment.
This mostly looks good; let me know what you think about the slight reframing I suggested.
| token_verifier: Optional[TokenVerifier] = None, | ||
| oauth: Optional[OAuthProviderSelector] = None, | ||
| allowed_origins: Optional[list[str]] = None, | ||
| native_redirect_uris: Optional[list[str]] = None, |
There was a problem hiding this comment.
So what this parameter does is it suppresses the consent screen for the given URIs. Are we sure that isn't also a feature that's useful with MCP clients? E.g. I could imagine a developer wanting to say "you can skip consent for chatgpt.com/[...]".
If that makes sense, then I imagine we rename this parameter to say what it does rather than what we expect to have put in there, e.g.:
| native_redirect_uris: Optional[list[str]] = None, | |
| skip_consent_for_redirect_uris: Optional[list[str]] = None, |
so that developers can write...
skip_consent_for_redirect_uris = [
"myapp://redirect",
"https://chatgpt.com/whatever_their_redirect_is",
]For bonus points, we could update the consent screen so that any non-https:// URI shows a subtle info-bubble with a message like...
if you're the developer of this app, you may want to add
skip_consent_for_uris=["<the_redirect_uri>"]to yourApplication(...)constructor.
| _SCHEME_REGEX = r"[a-zA-Z][a-zA-Z0-9+.\-]*" | ||
|
|
||
|
|
||
| def validate_native_redirect_uri(redirect_uri: object) -> None: |
There was a problem hiding this comment.
The redirect_uri comes out of a list[str] so should be typed as str.
| raise ValueError( | ||
| "`native_redirect_uris` must be a list of strings; got " | ||
| f"entry of type {type(redirect_uri).__name__}" | ||
| ) |
There was a problem hiding this comment.
native_redirect_uris is a field from Application in applications.py, it's not in this file. I don't love that this file (here and below) just randomly uses a name from a completely different file; while it's nice to not grow applications.py, it essentially means that this function is not reusable and might as well be inlined in applications.py.
What I'd suggest instead is this shape:
def validate_redirect_uris(redirect_uris: list[str], field_name: str) -> None:
for redirect_uri in redirect_uris:
if not isinstance(redirect_uri, str):
raise ValueError(f"`{field_name}` is 💩! Do better!")| token_verifier: Optional[TokenVerifier] = None, | ||
| oauth: Optional[OAuthProviderSelector] = None, | ||
| allowed_origins: Optional[list[str]] = None, | ||
| native_redirect_uris: Optional[list[str]] = None, |
There was a problem hiding this comment.
We're starting to grow quite a list of OAuth-specific parameters... Should we fold those into a single combined object?
| native_redirect_uris: Optional[list[str]] = None, | |
| oauth: Optional[OAuth] = None |
Or prefix them all with oauth_..?
| native_redirect_uris: Optional[list[str]] = None, | |
| oauth: Optional[OAuthProviderSelector] = None, | |
| oauth_allowed_origins: Optional[list[str]] = None, | |
| oauth_native_redirect_uris: Optional[list[str]] = None, |
Or... [other idea]?
What do you think?
| 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`: |
There was a problem hiding this comment.
This wastes words explaining what doesn't work; just limit the explanation to what to do for mobile apps.
| `nativeAuth({...})` from `@reboot-dev/reboot-react/native`: | ||
|
|
||
| ```tsx | ||
| const auth = expoAuth({ WebBrowser, SecureStore, Linking }); |
There was a problem hiding this comment.
Please document: are there other auth options than expoAuth? Where would I look to see my options?
| 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. |
There was a problem hiding this comment.
I strongly doubt that our developers want to read the words "discover, client registration, PKCE, the token exchange, [...]". They just want auth to work.
Simplify this. Tell developers what to do. Not what happens under the hood, the point is that Reboot takes care of it.
| 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. |
There was a problem hiding this comment.
This is an implementation detail for maintainers, not interesting to developers
| `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. |
There was a problem hiding this comment.
Another implementation detail the developer doesn't care about; they just want to know what to do to make it work.
| Running the app in a browser (`npm run web`) is the exception: its | ||
| redirect URI is an ordinary `http://localhost:<port>/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/`. |
There was a problem hiding this comment.
This paragraph can be removed if we go with the approach of generic "skip consent for this URL" with as-you-see-the-consent-screen hints for how to skip it.
React Native can't use Reboot's browser sign-in flow: there is no
window.locationto redirect and no cookie jar shared with the backend. Teach the same OAuth server and React client to run the standard authorization-code flow with PKCE that native apps use, so a mobile app writes the sameuseSignIn()/useSignOut()/useUser()as a web SPA does.Backend:
Application(native_redirect_uris=[...])lets an application claim the redirect URIs of its own first-party native apps. A client that registers only such URIs signs its user in directly, as the browser client already did; every other dynamically registered client still gets the consent screen, which is what stands between a user and an attacker who registers a client with their ownredirect_uri. Expo development URIs are trusted by shape underrbt dev runonly — deliberately not localhost, which MCP clients also register./__/oauth/whoamiadditionally accepts anAuthorization: Beareraccess token, so a native app can resolve itsdefault_idswithout hardcoding which state types are auto-constructed.React client:
RebootClientProvidertakes an optionalnativeAuth, anduseSignIn()/useSignOut()dispatch to it. Omitted — as on the web — everything behaves exactly as before.@reboot-dev/reboot-react/nativesubpath carries the whole OAuth protocol (discovery, RFC 7591 registration, PKCE, token exchange, refresh), plusexpoAuth()for Expo apps. It adds no dependency to@reboot-dev/reboot-react: the pieces React Native has no standard answer for are passed in and typed structurally, which also lets an app's own type-checker confirm its installed Expo version matches. SHA-256 is carried here rather than taken from a platform module, for the same reason.The
bank-pydanticmobile front end now signs in, and is scoped to the signed-in user like the web front end.That might be helpful to look through that preso before checking the PR to get a high level understanding of things going on here:
https://claude.ai/code/artifact/59800771-8380-410d-84b4-a8b59b624ec2
Also I tested the whole flow with a real phone setup!