@runablehq/managed-auth
v0.6.0
Published
Runable managed auth — client plugins for web, Expo/React Native, and Electron apps, server plugin for generated apps, broker plugin for the Runable API
Readme
@runablehq/managed-auth
Turnkey Google / Apple / Microsoft sign-in for apps built on Runable, on every app surface: web,
Expo/React Native, and Electron. Runable owns the upstream provider OAuth apps and the fixed
provider callback, authenticates the end user, and hands the app a short, aud-scoped identity
JWT signed by the broker. The app verifies that JWT offline against the broker's JWKS and
mints its own Better Auth session — so the app keeps its session, revocation, and lifetime,
while never touching provider credentials.
Generated apps configure nothing auth-provider-related: no client secret, no redirect-URI
registration, no genericOAuth. Just the app id and broker issuer Runable already injects.
Env contract
Runable injects these into the app:
# root .env
APPLICATION_ID= # this app's id; the JWT's `aud` is bound to it (server reads this)
VITE_RUNABLE_AUTH_ISSUER= # broker base, e.g. https://api.runable.com/api/auth (server + web client)
VITE_APPLICATION_ID= # browser copy of APPLICATION_ID for the web clientThe mobile package's app.json carries the same values under platform-managed expo.extra:
applicationId, runableAuthIssuer, and apiUrl (the app server the auth client targets).
expo.scheme is also platform-managed (runable-<applicationId>) — it is the deep-link protocol
the broker validates for standalone mobile builds and the desktop app.
issuer is required everywhere: there is no default broker, so a staging app can never
silently call production.
Web (/client)
import { createAuthClient } from "better-auth/react";
import { managedAuthClient } from "@runablehq/managed-auth/client";
export const authClient = createAuthClient({
baseURL: window.location.origin,
basePath: "/api/auth",
plugins: [
managedAuthClient({
applicationId: import.meta.env.VITE_APPLICATION_ID,
issuer: import.meta.env.VITE_RUNABLE_AUTH_ISSUER,
}),
],
});
// once, on boot — completes a returning redirect/popup:
await authClient.managedAuth.handleRedirect();
// a sign-in button — useSession() updates when it resolves:
authClient.managedAuth.signIn({ provider: "google" }); // "google" | "apple" | "microsoft"signIn opens a first-party popup inside the cross-origin preview iframe and a top-level redirect
otherwise. The plugin attaches the app's bearer session to every Better Auth request and exchanges
through the client's own baseURL/basePath.
Expo / React Native (/native)
Peers (all bundled in Expo Go): expo-web-browser, expo-secure-store, expo-linking,
expo-crypto.
import { createAuthClient } from "better-auth/react";
import { managedAuthExpoClient } from "@runablehq/managed-auth/native";
import Constants from "expo-constants";
const extra = Constants.expoConfig?.extra ?? {};
export const authClient = createAuthClient({
baseURL: extra.apiUrl, // the app's server — the exchange and session ride through it
basePath: "/api/auth",
plugins: [
managedAuthExpoClient({
applicationId: extra.applicationId,
issuer: extra.runableAuthIssuer,
}),
],
});On native, signIn runs the whole flow in a system auth session (openAuthSessionAsync): the
broker 302s to Linking.createURL("auth/callback") — exp://…/--/auth/callback in Expo Go, the
app scheme in standalone builds — and the promise resolves signed-in, with the bearer stored in
SecureStore. Ship a route at the redirect path (e.g. app/auth/callback.tsx) so the router has
somewhere to land. On Expo Web the plugin delegates to the web transport, so call
handleRedirect() once on boot (a no-op on native). The native/desktop legs are PKCE-bound: an
intercepted deep link can't be redeemed without the in-memory verifier.
Electron (/desktop/main, /desktop/preload)
The renderer signs in through the system browser (Google blocks OAuth in embedded user
agents) and completes over the app's custom protocol. The renderer side is just
managedAuthClient — it detects the Electron bridge itself — while the main and preload halves
ship as one entry per process because they can't share a bundle (the web bundle must build
without electron; the main half imports electron and node:http). The entries register
mechanism only — app control flow (single-instance lock, quitting, window creation, what gets
exposed to the page) stays in app code:
// main process — registers runable-<applicationId>, delivers macOS open-url + argv deep links
// to the renderer (buffered until the first window loads), registers the managed-auth IPC
// surface, and runs the dev loopback relay in unpackaged builds.
import { createManagedDeepLinks } from "@runablehq/managed-auth/desktop/main";
const deepLinks = createManagedDeepLinks({
applicationId: process.env.APPLICATION_ID,
getWindow: () => win,
});
// Windows/Linux deliver deep links as argv — of a second instance while running, of this
// instance on cold start. Keep one instance and forward both.
if (app.requestSingleInstanceLock()) {
app.on("second-instance", (_event, argv) => deepLinks.handleArgv(argv));
app.whenReady().then(() => {
createWindow();
deepLinks.handleArgv(process.argv);
});
} else {
app.quit();
}// preload — the app exposes the bridge itself (must be bundled in; preloads are sandboxed)
import { contextBridge } from "electron";
import { createManagedAuthBridge } from "@runablehq/managed-auth/desktop/preload";
// window.managedAuth — where managedAuthClient looks for the Electron bridge
contextBridge.exposeInMainWorld("managedAuth", createManagedAuthBridge());// renderer — the regular web plugin detects window.managedAuth and switches to the
// system-browser + deep-link flow on its own; no desktop-specific client code needed.
import { createAuthClient } from "better-auth/react";
import { managedAuthClient } from "@runablehq/managed-auth/client";
export const authClient = createAuthClient({
baseURL: import.meta.env.VITE_WEBSITE_URL, // packaged apps load from file:// — never the page origin
basePath: "/api/auth",
plugins: [
managedAuthClient({
applicationId: import.meta.env.VITE_APPLICATION_ID,
issuer: import.meta.env.VITE_RUNABLE_AUTH_ISSUER,
}),
],
});The deep-link stream is not auth-specific: every URL on the app's scheme reaches
window.managedAuth.onDeepLink, so app features can build on it (the bridge methods are plain
functions — re-expose them under an app-owned key if preferred).
Server (/server)
Spread the plugins into the app's own Better Auth — it keeps email/password, the database, etc.:
import { betterAuth } from "better-auth";
import { runableManagedAuth } from "@runablehq/managed-auth/server";
export const auth = betterAuth({
basePath: "/api/auth",
baseURL: process.env.WEBSITE_URL,
// ...your database, emailAndPassword, etc.
plugins: [
...runableManagedAuth({
applicationId: process.env.APPLICATION_ID!,
issuer: process.env.VITE_RUNABLE_AUTH_ISSUER!,
}),
// add framework plugins (e.g. expo()) here
],
});runableManagedAuth returns bearer() plus a plugin that adds POST /api/auth/managed/exchange,
which verifies the broker JWT (offline, via JWKS, aud = APPLICATION_ID, PKCE when the token is
challenge-bound) and mints the app's bearer-only session. The clients call it for you — no route
wiring needed. Install the peers: Better Auth (>=1.6.19) and Zod (>=4).
Broker (/broker) — Runable internal
Mounted by the Runable API (not by apps): createManagedAuthStart(...) exposes /start (validate
the redirect target against the application record, then drive the provider), and
managedAuthBrokerPlugin(...) is a Better Auth plugin that mints the identity JWT inside the
authenticated social callback and delivers it to the validated target in the URL fragment —
origin-rooted for https, full-path for custom-scheme deep links. No per-app OAuth client and no
separate /finish.
Versions & debugging
CHANGELOG.md documents every published version with breaking changes and
agent migration steps. When a generated app's auth code doesn't match these docs, check the app's
installed version first.
Playground
playground/ is a self-contained harness covering every client surface, against a broker serving
/api/auth/managed/* (RUNABLE_AUTH_ISSUER in playground/.env — e.g. a local Runable API):
bun run playground— the app server plus both web contexts: top-level redirect at:4321, popup-in-iframe preview at:4322.bun run playground:desktop— an Electron shell (mirrors the template'spackages/desktop) built oncreateManagedDeepLinks+createManagedAuthBridge;managedAuthClientdetectswindow.managedAuthand switches to the system-browser flow, returning via therunable-<APPLICATION_ID>deep link (or the dev loopback relay). A localhost broker accepts that scheme without a DB-backed application. Runbun run buildfirst — the shell consumes the built package.bun run playground:mobile— an Expo Go app (mirrors the template'spackages/mobile) usingmanagedAuthExpoClient; copyplayground/mobile/.env.templateto.envfirst. Simulators reach the app server on localhost; physical devices need the dev machine's LAN IP in both URLs.
All three mint sessions on the same in-memory app server (playground/server.ts).
