Skip to content

Add native (React Native) OAuth sign-in - #123

Open
onelxj wants to merge 1 commit into
mainfrom
onelxj.native_oauth_wip
Open

Add native (React Native) OAuth sign-in#123
onelxj wants to merge 1 commit into
mainfrom
onelxj.native_oauth_wip

Conversation

@onelxj

@onelxj onelxj commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

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.

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!

@aviator-app

aviator-app Bot commented Aug 5, 2026

Copy link
Copy Markdown

Current Aviator status

Aviator will automatically update this comment as the status of the PR changes.
Comment /aviator refresh to force Aviator to re-examine your PR (or learn about other /aviator commands).

This pull request is currently open (not queued).

How to merge

To merge this PR, comment /aviator merge or add the mergequeue-ready label.


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.

@onelxj
onelxj marked this pull request as draft August 5, 2026 15:15
@onelxj
onelxj force-pushed the onelxj.native_oauth_wip branch 2 times, most recently from ced66bf to bcbaffb Compare August 6, 2026 21:39
@onelxj
onelxj marked this pull request as ready for review August 6, 2026 21:39
@onelxj
onelxj requested review from benh and rjhuijsman and removed request for benh August 6, 2026 21:39
@onelxj onelxj self-assigned this Aug 6, 2026
@onelxj
onelxj requested a review from rileysdev August 6, 2026 21:40
@onelxj
onelxj force-pushed the onelxj.native_oauth_wip branch from bcbaffb to 084a070 Compare August 6, 2026 21:47
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>
@onelxj
onelxj force-pushed the onelxj.native_oauth_wip branch from 084a070 to 06a3f64 Compare August 6, 2026 21:48

@rjhuijsman rjhuijsman left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.:

Suggested change
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 your Application(...) constructor.

_SCHEME_REGEX = r"[a-zA-Z][a-zA-Z0-9+.\-]*"


def validate_native_redirect_uri(redirect_uri: object) -> None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The redirect_uri comes out of a list[str] so should be typed as str.

Comment on lines +50 to +53
raise ValueError(
"`native_redirect_uris` must be a list of strings; got "
f"entry of type {type(redirect_uri).__name__}"
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're starting to grow quite a list of OAuth-specific parameters... Should we fold those into a single combined object?

Suggested change
native_redirect_uris: Optional[list[str]] = None,
oauth: Optional[OAuth] = None

Or prefix them all with oauth_..?

Suggested change
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?

Comment on lines +19 to +25
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`:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please document: are there other auth options than expoAuth? Where would I look to see my options?

Comment on lines +33 to +37
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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +46 to +51
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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an implementation detail for maintainers, not interesting to developers

Comment on lines +53 to +56
`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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another implementation detail the developer doesn't care about; they just want to know what to do to make it work.

Comment on lines +67 to +72
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/`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants