---
summary: "The full path from a hardcoded backend to a working public/native OAuth\
  \ client \u2014 what the app had to implement, what the identity provider had to\
  \ implement, and the recommendation we had to push back on."
audience: engineers packaging a ScaiLabs web app as a desktop or mobile shell
title: 'Taking a web app native: what actually had to change'
path: tutorials/native-app-oauth
status: published
---

# Taking a web app native: what actually had to change

ScaiTerm ships one frontend in three wrappers — a browser SPA, and Tauri v2
shells for desktop and mobile. The SPA has worked for months. When we went to
package it, **it could not authenticate at all**, and the reasons were not the
ones we expected.

This is the whole path, in order, including the parts we got wrong. Roughly half
the work was ours and half was ScaiKey's, and the split is not where we assumed
it would be when we started.

## It started with a hardcoded hostname

The trigger was mundane: the standalone build pointed at `scaiterm.scailabs.ai`
and self-hosting is a first-class posture for us, so it needed to be
configurable. Pulling that thread exposed something bigger.

The web client was **origin-relative everywhere** — `fetch("/v1/…")`,
`location.host` for the WebSocket. That is correct in a browser served by the
proxy that also fronts the API, and meaningless in a packaged app, which loads
from the app bundle. `location.host` is not a server; there is no origin to be
relative to.

So the first piece of work was a single resolver with an explicit order:

1. what the user chose (persisted locally, changeable before sign-in)
2. `VITE_BACKEND_URL` baked in at build time (a vendor-specific build)
3. the app's own origin, when it has a real one
4. the public service

The hosted browser build resolves to `""` — an empty prefix, byte-identical to
how it always behaved. Only a build with no web origin (`tauri://`, `file://`)
falls through to a default. That property mattered more than it sounds: it meant
the change could ship to production the same day without touching how any
existing user's client behaves.

**The server picker has to live on the sign-in screen**, not in settings. Which
backend you are talking to decides *which identity provider you are sent to*, so
it cannot be something you configure after logging in. We validate an address
before accepting it (fetch its `/v1/auth/config`, check it looks like a
ScaiTerm), then clear tokens and reload — the OIDC client, the issuer, the
sessions and the catalog all belong to one deployment, so switching is a
restart, not a state change.

## Cross-origin: an allowlist, and a module in the right place

A packaged app's origin is `tauri://localhost`. Every call it makes is
cross-origin, including the very first one — fetching the OIDC configuration it
needs to *start* signing in. Without CORS the app cannot begin.

Two decisions worth stating:

**An allowlist, never `*`.** A wildcard makes "which apps may talk to this
backend" permanently unanswerable. The default list is the product's own clients
(the app URL plus the shells' origins); a deployment can replace it, or set the
variable empty to switch cross-origin access off entirely. Presence of the
variable is the signal, so turning it off does not require knowing a magic
value.

**Credentials off.** We authenticate with a Bearer token the client holds in
storage, never a cookie, so there is nothing for a browser to attach to a
cross-origin request on its own. `allow_credentials` would only widen what a
hostile page could attempt, in exchange for nothing.

The implementation detail that bit us: the control plane and the auth BFF are
**separate processes** that must agree on that allowlist, and neither can import
the other's package. Our first attempt put the policy in the relay package and
imported it from the BFF — which pulled in the whole protocol core, failed with
`ImportError`, and was caught by a `try/except` that fell back to *no CORS at
all*. It would have shipped silently and the shells would have been blocked with
no error to explain it.

Shared deployment policy between two composition roots belongs **with the
composition roots**, not inside one of the things it configures.

## The real blocker was a redirect URI

With the transport sorted, the actual problem surfaced. Our SPA does
authorization-code + PKCE and hands the `code` to a small BFF, which adds the
`client_secret` server-side. Right shape for a web app on an origin you control.
A packaged app has neither property:

- **No web origin.** A registered `redirect_uri` of `https://…/auth/callback`
  lands the user in a *browser*, on a page with no way to hand tokens back to
  the app that started the flow.
- **No safe place for a secret.** Anything in a shipped binary is public
  (RFC 8252 §8.5). The app must be a **public client**, with PKCE as its only
  proof.

That is not something a frontend can solve. It needs a client registration, so
we wrote it up and sent it to the ScaiKey team as five specific questions rather
than a bug report.

## What ScaiKey had to implement

Their reply was verified against their running code rather than their docs,
which is the standard worth copying. Six things came out of it:

| | Item | Why it mattered |
|---|---|---|
| 1 | A public `NATIVE` client registration | PKCE-only, no secret issued or expected |
| 2 | **PKCE mandated for public clients on the platform authorize endpoint** | it was only enforced on the tenant endpoint |
| 3 | A CLI command so self-hosters can register their own | `scaikey create-native-client` |
| 4 | Reject `plain` / a missing PKCE method | it silently accepted a downgraded challenge |
| 5 | Refresh-token family revocation on reuse | RFC 6819 §5.2.2.3 |
| 6 | Advertise `none` in `token_endpoint_auth_methods_supported` | make the public-client contract discoverable |

Items 2, 4 and 5 are the interesting ones, and none were on our original list of
five questions — they came out of the provider reading their own code carefully
enough to volunteer the gaps.

**Item 2 nearly went the wrong way,** and it is the part most likely to repeat
elsewhere. Their recommendation was to use the tenant-scoped authorize endpoint,
where the mandate already existed, on the reasoning:

> "Since your app is pointed at a specific deployment before sign-in — i.e. you
> know the tenant …"

That inference does not hold. **Pointing an app at a backend does not tell you
the user's tenant.** One ScaiTerm deployment serves many tenants and partners at
once; when someone opens the app we know which server they are talking to and
nothing whatsoever about who they are. Home-realm discovery on the platform
endpoint is what resolves that. Taking the recommendation would have meant asking
users to pick a tenant slug before signing in — precisely the friction we had
just removed from the web client.

So we pushed back and asked for the mandate on the platform endpoint instead, and
argued it as a prerequisite rather than a nice-to-have: a public client has no
secret to fall back on, so *"the server requires proof"* and *"the client
remembers to send proof"* are not equivalent guarantees. The binary is untrusted
by definition — that is what public means.

They implemented it at **both** places a code can be minted: the authorize entry
and the tenant *complete* chokepoint, where a platform login actually issues its
code after discovery resolves the tenant. Guarding only the entry would have
looked correct and done nothing for a normal fresh login.

## What we verified instead of believing

Every claim above was checked against the live endpoint before we built on it. It
takes minutes and it is not a trust exercise — it is how you find out that a
deployment did what a changelog says.

```bash
# a public client with no PKCE must be refused
curl -sD - "$AUTHORIZE?response_type=code&client_id=$NATIVE_ID\
&redirect_uri=scaiterm%3A%2F%2Fauth%2Fcallback&scope=openid&state=x" | grep -i location

location: scaiterm://auth/callback?error=invalid_request&error_description=
          PKCE+with+code_challenge_method%3DS256+is+required+for+public+clients&state=x
```

`code_challenge_method=plain` gets the same refusal; a correct S256 request
proceeds to the login UI with `platform=true`, so home-realm discovery is intact.

Two things that check taught us, beyond a green tick:

- **Errors arrive on the app's own scheme.** The rejection came back as
  `scaiterm://auth/callback?error=…`, not as an HTTP error page. A shell
  receives failures through the same deep link as successes, so the deep-link
  handler must branch on `error` before it looks for `code`. Nothing in the
  specification made that vivid; one `curl` did.
- **Assumptions die cheaply here.** Ours was that a sibling product had already
  solved native OAuth, so a pattern existed to copy. The provider checked their
  database and found a plain confidential web app with a client secret and an
  https redirect. There was no pattern. Better to learn that in a paragraph than
  three days into an implementation.

## What we implemented on our side, finally

The last piece is small and is the one that makes self-hosting work: the backend
tells the app which client to be.

```jsonc
GET <backend>/v1/auth/config
{
  "client_id": "…", "redirect_uri": "https://…/auth/callback",   // the web SPA
  "native": {                                                     // the shells
    "client_id": "…",
    "redirect_uri": "scaiterm://auth/callback",
    "post_logout_redirect_uri": "scaiterm://auth/logout",
    "code_challenge_method": "S256",
    "token_endpoint": "https://…/oauth/token"
  }
}
```

Served to every client, browser included — a `client_id` is public by design and
there is no secret in that document. The `token_endpoint` is there because a
native app exchanges the code itself, with no BFF in the path, and it comes from
discovery rather than string-assembly.

**Nothing is baked into the app.** A self-hoster registers a native client in
their own identity provider, puts the id in their own backend's config, and the
app picks it up when a user points at that backend. No well-known constant, no
assumption that every install shares an id, and no second configuration
mechanism — it rides the one already added for the backend URL. The `native`
block is absent entirely when no native client is registered, so a deployment
that has not set one up does not look like it has.

## What we deliberately did not build yet

The client-side native flow — direct PKCE exchange, deep-link handling, native
logout — is not written. It can only run inside a shell that does not exist yet,
and an OAuth path you cannot execute is how you ship a login that fails in the
field. The server contract it needs is in place and verified; that work is
unblocked, not done.

One loose end worth naming: the staging client's `allowed_scopes` are wider than
what we request (`openid profile email`). A public client shipped in a binary
should not carry directory-read scopes it never uses, so that gets trimmed before
general availability.

## If you are doing this next

- **Find your origin assumptions first.** Grep for `location.host`,
  `location.origin` and bare `/api` paths. Everything origin-relative is a thing
  a packaged build cannot do.
- **Decide where config comes from before you write any of it.** "The backend
  tells the app" costs the same as "the app knows" and gives you self-hosting for
  free.
- **A private-use scheme (`yourapp://auth/callback`) beats loopback.** RFC 8252
  §7.3 loopback needs the provider to ignore the port when matching, and exact
  string matching is the common case — check before you design around it.
- **Ask for a server-side PKCE mandate**, not just support. A public client that
  *can* skip its proof eventually will, in some version, on some platform.
- **Handle `error` on the deep link.** It is the same entry point as success.
- **Verify the provider's deployment yourself**, with three `curl`s, before you
  build on it.
- **Write the request down.** Five specific questions got six implemented
  changes, including three gaps we had not thought to ask about. A bug report
  would not have.
