Platform
ScaiWave ScaiGrid ScaiCore ScaiBot ScaiDrive ScaiKey Models Tools & Services
Solutions
Organisations Developers Internet Service Providers Managed Service Providers AI-in-a-Box
Resources
Support Documentation Blog Downloads
Company
About Research Careers Investment Opportunities Contact
Log in

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 everywherefetch("/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
1
2
3
4
5
6
# 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 curls, 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.
Updated 2026-09-03 08:52:49 View source (.md) rev 1