Users & Auth  /  Authentication

Authentication

Sign users into your app, and let your server trust who they are. The client SDK opens a hosted sign-in page — no UI to build, no passwords to store — and your server verifies the resulting token with your secret key. Use whichever half you need, or both.

Applies to Client · frontend Server · backend

1Overview

Onelo Auth handles the whole sign-in flow for you. On the client, one view opens the Onelo-hosted page (email & password, or social sign-in with Google / GitHub / Apple, on every plan) and stores the returned tokens securely on-device. On the server, one call verifies a user's token against Onelo before you serve protected data. There is no login form to build and no password to store — Onelo never hands you a raw credential.

No lock-in: your end users stay yours — export them and move away any time

Onelo Auth is the default sign-in; an app that already has Supabase, Clerk, Firebase or another OpenID Connect sign-in connects it instead. Your end users stay yours: with Onelo Auth, export the user list (CSV) and their password hashes any time from Dashboard → Users, and move them elsewhere without forcing password resets.

2How it works

01
Open hosted page

The SDK launches the Onelo sign-in page in a native window (or iframe/popup on web).

02
User signs in

Email & password, or OAuth with Google / GitHub / Apple — all on Onelo’s page.

03
One-time code

A single-use code returns to your app — deep link (your app’s own unique callback scheme, from Settings) on native, postMessage on web.

04
Exchange for token

The SDK swaps the code for tokens, stored on device.

3Install & integrate

Read the panel for your stack — everything's shown, nothing's hidden behind a picker. Building the common frontend + backend flow? Both halves are below with the token handoff between them. Every snippet renders from @onelo/snippets — the same source the dashboard uses.

↺This is where you set up Onelo. You install & initialize the client once here; other modules (Features, Monitor) reuse the same client and show only their module-specific calls.
① Clientfrontend

Install

// Xcode → File → Add Package Dependencies: // https://github.com/onelo-tools/onelo-swift

Initialize

import OneloSwift

// 1. Register a URL scheme for the login callback.
//    In Xcode: target → Info → URL Types → add your scheme (e.g. "myapp").
//    This lets the hosted login page redirect back to your app after sign-in.

// 2. Set up auth in your App entry point
@main struct MyApp: App {
    @StateObject var auth = OneloAuth(
        config: .init(
            publishableKey: "onelo_pk_live_YOUR_KEY",
            apiUrl: URL(string: "https://api.onelo.tools")!,
            callbackScheme: "myapp"  // replace with your URL scheme from step 1
        )
    )
    var body: some Scene {
        WindowGroup {
            // OneloAuthView handles login, signup and password reset.
            //
            // ⚠️ THIS CLOSURE RUNS ONLY WHEN THE USER IS SIGNED IN **AND** ENTITLED
            //    — that is: your app has no paywall, or the user holds active
            //    access. A signed-in user WITHOUT a plan never reaches it; they
            //    are routed to your store (or, on Apple with the Access Gate on,
            //    to a "no active plan" screen).
            //
            //    So do NOT put "the user just signed in" side effects here —
            //    closing your own login window, starting a session, flipping app
            //    state. On a paywalled app they will never fire for a plan-less
            //    user and the login window stays open over a perfectly valid
            //    session.
            //
            //    DRIVING YOUR OWN WINDOWS? Observe auth.$isAllowedIn — the single
            //    signal for "this user should see the app". Do NOT rebuild it from
            //    currentSession: that means SIGNED IN, and a user with no plan is
            //    signed in while still belonging in the store or on "no active
            //    plan". Do NOT use hasActiveAccess either: it means HAS A PAID
            //    GRANT, so on an app with no paywall it is false for everyone and
            //    would lock out every legitimate user.
            //
            //      auth.$isAllowedIn
            //          .sink { allowed in if allowed { self.showApp() } }
            //          .store(in: &cancellables)
            //
            //    It already waits for the remote config, which is the trap in a
            //    hand-rolled version: paywallEnabled is false until config lands,
            //    so a naive check says "allowed in" to everyone right after launch.
            //    Needs onelo-swift 3.80.0 or newer.
            OneloAuthView(auth: auth) {
                ContentView()
                    .environmentObject(auth)
            }
            // 3. Finish sign-ins that come back from OUTSIDE the app.
            //    REQUIRED if you enable Magic link (dashboard → Auth →
            //    Passwordless). The email opens in the user's browser, which then
            //    hands the one-time code back over the scheme from step 1 —
            //    without this line the code arrives at your app and nothing
            //    catches it, so the link appears to do nothing.
            //    Harmless to include when magic link is off, and it also covers
            //    any future flow that returns from the system browser.
            //    Needs onelo-swift 3.79.1 or newer.
            .onOpenURL { url in
                Task { try? await auth.handleAuthCallback(url) }
            }
            //    AppKit app with an NSApplicationDelegate instead of SwiftUI
            //    scenes? Implement this on the delegate and call the same method:
            //      func application(_ application: NSApplication, open urls: [URL]) {
            //          for url in urls {
            //              Task { @MainActor in try? await onelo.auth.handleAuthCallback(url) }
            //          }
            //      }
            //    handleAuthCallback returns nil for URLs that aren't Onelo's, so
            //    you can pass every incoming URL through it without filtering.
            //
            //    On macOS it also brings your app to the front. The OS delivers the
            //    URL but does not focus you, so with the browser on another display
            //    or Space the sign-in would otherwise complete out of sight and look
            //    broken.
            //
            //    MENU-BAR / AGENT APPS (NSApp.setActivationPolicy(.accessory)):
            //    activating is not enough — an accessory app does not come forward
            //    like a normal one. Order your own window front as well:
            //      NSApp.setActivationPolicy(.regular)
            //      window.collectionBehavior.insert(.moveToActiveSpace)
            //      window.makeKeyAndOrderFront(nil)
            //    .moveToActiveSpace shows it on the desktop the user is looking at
            //    rather than switching them to another one.
        }
    }
}

Usage

// 4. OneloAuthView always opens the centrally-hosted sign-in page —
//    on every plan. Email / password stays inside the embedded
//    WKWebView. Social providers (Google, GitHub, Apple) are handed
//    off to ASWebAuthenticationSession automatically — required
//    by Apple App Store Review and gives buyers their cached
//    SSO session in Safari, so no re-login + 2FA every time. Branding
//    (colors, logo, copy) is configured in the Onelo dashboard.

// 5. Access the current user anywhere in your app
struct ContentView: View {
    @EnvironmentObject var auth: OneloAuth

    var body: some View {
        VStack {
            if let user = auth.currentSession?.user {
                Text("Hello, \(user.email ?? "user")")
                Button("Sign out") {
                    Task { try? await auth.signOut() }
                }
            }
        }
    }
}

// ── Automatic remote-logout ─────────────────────────────────
// You don't need to write any code for this — OneloAuthView observes
// auth.currentSession (which is @Published) and AUTOMATICALLY shows
// the sign-in screen when the session is cleared remotely. Delivery
// is instant (sub-second) over a Server-Sent Events channel the SDK
// opens after sign-in, with a 13-minute heartbeat fallback in case
// the long-lived connection is blocked by a corporate firewall.
// Triggers that clear the local session:
//   • Customer self-requested account deletion via /customer/portal.
//   • Refund issued AND no eligible free-tier plan to fall back to
//     (the app you sell becomes unusable for that buyer; SDK kicks them
//     out so they can re-purchase or close the app).
//   • You suspended/banned the user in the Onelo admin dashboard.
//   • Hard-delete by the 30-day account-deletion cron job.
//
// REQUIREMENTS so the auto-logout actually shows the sign-in screen:
//   1. OneloAuthView MUST be your ROOT view — above any NavigationStack,
//      TabView, or custom router. If it's nested inside, navigation pop
//      stays on whatever screen the user was on when revoked.
//      ❌ NavigationStack { OneloAuthView(auth: auth) { ContentView() } }
//      ✅ OneloAuthView(auth: auth) { NavigationStack { ContentView() } }
//   2. In ContentView observe auth via @EnvironmentObject / @ObservedObject —
//      never capture it into a local 'let' (snapshot won't update on revoke).
//      ❌ let user = auth.currentSession?.user; if user != nil { … }
//      ✅ if let user = auth.currentSession?.user { … }
//   3. If you maintain your own NavigationPath, reset it on revoke so the
//      stack doesn't outlive the session:
//        .onChange(of: auth.currentSession) { newSession in
//            if newSession == nil { navigationPath = .init() }
//        }
//
// Full working example with NavigationStack + path reset:
//
//   @main struct MyApp: App {
//       @StateObject var auth = OneloAuth(config: .init(/* … */))
//       var body: some Scene {
//           WindowGroup {
//               // OneloAuthView wraps the whole NavigationStack so that on
//               // remote-revoke the entire stack is replaced by the hosted
//               // sign-in page — no leftover pushed screens.
//               OneloAuthView(auth: auth) {
//                   RootView().environmentObject(auth)
//               }
//           }
//       }
//   }
//
//   struct RootView: View {
//       @EnvironmentObject var auth: OneloAuth
//       @State private var path = NavigationPath()
//
//       var body: some View {
//           NavigationStack(path: $path) {
//               HomeView()
//                   .navigationDestination(for: Route.self) { /* … */ }
//           }
//           // Belt-and-suspenders: reset the path on revoke so deep
//           // links don't survive the logout. OneloAuthView already
//           // re-presents the sign-in page; this just clears the stack
//           // behind it so the user lands on HomeView after re-login.
//           .onChange(of: auth.currentSession) { newSession in
//               if newSession == nil { path = NavigationPath() }
//           }
//       }
//   }
//
// If you want to show a one-time "your account was deleted" toast
// before re-presenting the sign-in screen, observe the
// auth.isUserRevoked flag (also @Published):
//
//   .onChange(of: auth.isUserRevoked) { revoked in
//       if revoked { showAccountDeletedToast() }
//   }
//
// ── AppKit / multi-window apps ──────────────────────────────
// The "OneloAuthView as root" pattern above assumes a single SwiftUI
// WindowGroup. Menu-bar utilities, floating widgets, and multi-window
// editors (separate NSWindow instances for face / bubble / chat / auth)
// don't fit that shape — your main user-facing window is NOT the one
// hosting OneloAuthView, so the SwiftUI re-render on revoke can't
// hide it. Drive your windows from Combine instead.
//
// ⚠️ TWO DIFFERENT SIGNALS. Do not use one for both directions.
//
//   SHOW YOUR APP  →  auth.$isAllowedIn   (signed in AND allowed in)
//   HIDE YOUR APP  →  auth.$currentSession going non-nil → nil (revoke)
//
// Using $currentSession for the "show" direction is the mistake this warning
// exists for: a user with no plan on a paywalled app IS signed in, and letting
// them in on that signal walks straight past your paywall and past the Apple
// Access Gate. $isAllowedIn is false for them, and flips true by itself the
// moment they buy — which the session signal never does.
//
//   auth.$isAllowedIn
//       .removeDuplicates()
//       .sink { [weak self] allowed in
//           guard allowed else { return }
//           Task { @MainActor in self?.showApp() }   // close login window, bring UI up
//       }
//       .store(in: &cancellables)
//
// Needs onelo-swift 3.80.0 or newer. Below that, build it yourself as
//   isReady && currentSession != nil && (!paywallEnabled || hasActiveAccess)
// and note the isReady term: paywallEnabled is false until the remote config
// lands, so without it every user looks allowed in for the first moments.
//
// The revoke direction, which is a different edge entirely:
//
//   import Combine
//
//   private var cancellables = Set<AnyCancellable>()
//   private var hadSession = false
//   private var isSigningOut = false  // set true around your own auth.signOut() call
//
//   auth.$currentSession.sink { [weak self] session in
//       guard let self else { return }
//       let has = session != nil
//       defer { self.hadSession = has }
//       // Fire only on non-nil → nil (the actual revoke moment).
//       // Skip the user-initiated Sign out path you already handle.
//       if self.hadSession && !has && !self.isSigningOut {
//           Task { @MainActor in
//               self.mainWindow.orderOut(nil)
//               self.otherWindows.forEach { $0.orderOut(nil) }
//               self.showAuthWindow()   // your NSWindow hosting OneloAuthView
//           }
//       }
//   }.store(in: &cancellables)
//
// Note: OneloAuthView hosted in a hidden / minimized / off-screen
// NSWindow will NOT react to remote-revoke. Bring the window forward
// (orderFrontRegardless / makeKeyAndOrderFront) BEFORE you expect the
// hosted page to appear.

// ── Automatic legal-consent gate ────────────────────────────
// You don't need to write any code for this. When you publish a
// material update to your Terms of Service in the Onelo dashboard,
// OneloAuthView automatically shows the updated document — in the
// SAME hosted WKWebView used for sign-in — the next time the user
// opens the app after the effective date. The user must tap
// "Accept & Continue" to reach your content(), or "Sign out".
// There is no "Decline" button: nothing to mis-tap.
//   • Terms of Service → blocks the app until accepted (legal
//     requirement in DE/PL: silent "acceptance by continued use"
//     is not valid — an explicit tap is required).
//   • Privacy Policy / DPA / Cookies → users are notified by email
//     and the app is not blocked (GDPR information duty) — UNLESS you
//     published the version with "Require acceptance", which shows the
//     same blocking gate as Terms.
//
// This automatic gate only works WHILE OneloAuthView is mounted — i.e. when it
// wraps your content() as the root view (the pattern in step 2).
//
// ⚠️ IF YOU USE OneloAuthView ONLY FOR SIGN-IN and switch to your own UI after
// login (OneloAuthView is no longer in the view tree), the automatic gate
// cannot fire — nothing is left to host it. In that case add ONE line to your
// post-login root so the gate is enforced on YOUR UI:
//
//   struct HomeView: View {
//       @EnvironmentObject var auth: OneloAuth
//       var body: some View {
//           MyAppContent()
//               .oneloConsentGate(auth: auth)   // ← blocking gate on your own UI
//       }
//   }
//
// .oneloConsentGate presents a full-cover hosted gate OVER your UI when a
// blocking version is pending (Accept & Continue / Sign out, no dismiss). It
// re-checks on appear, on app-foreground, and on the real-time SSE push — so a
// running, logged-in app shows the gate instantly when you publish.
//
//   • Terms of Service → blocks the app until accepted (legal requirement in
//     DE/PL: silent "acceptance by continued use" is not valid).
//   • Privacy / DPA / Cookies → notified by email only; never blocks the app
//     (GDPR information duty) unless you published it with "Require acceptance".
//
// Advanced (optional): read pending consents yourself, e.g. for a custom banner —
//   let pending = await auth.requiredConsents()
//   let mustAccept = pending.filter { $0.blocking }   // blocking, past effective
// Recording acceptance from your own UI:
//   try await auth.acceptConsent(versionId: requirement.versionId)
② Serverbackend

Install

npm install github:onelo-tools/onelo-node

Initialize

import { Onelo } from '@onelo/node'
import { requireUser } from '@onelo/node/express'

// Authenticate with your SERVER SECRET key (onelo_sk_live_…) — NOT a
// publishable key. Read it from the environment; never hard-code a secret.
const onelo = new Onelo({
  secretKey: process.env.ONELO_SECRET_KEY!,   // onelo_sk_live_…  (NEVER commit)
  apiUrl: 'https://api.onelo.tools',
})

Usage

// The frontend sends the user's ACCESS TOKEN (returned by the Onelo SDK after
// sign-in — NOT your secret key) as: Authorization: Bearer <token>.
// requireUser verifies it and attaches req.oneloUser. When the App's Sign-in
// Mode is an external provider (Supabase, Clerk, Firebase…), requireUser also
// accepts that provider's token, so the frontend keeps sending what it sends.
app.get('/me', requireUser(onelo), (req, res) => {
  // requireUser guarantees req.oneloUser is set here — assert non-null.
  res.json({ id: req.oneloUser!.id, email: req.oneloUser!.email })
})

// Post-verify gates + SSE support (all optional):
//   requireUser(onelo, { acceptQueryToken: true })   // allow ?token= for EventSource/SSE
//   requireUser(onelo, { identifyMonitor: false })   // opt out of auto monitor.setUser (on by default when a monitor scope is active)
//   optionalUser(onelo)   // sets req.oneloUser = null instead of 401 (a 503 outage still surfaces)

// ── Other frameworks — one adapter import, same onelo client ──
//   Next.js  → import { withUser } from '@onelo/node/next'
//              export const GET = withUser(onelo, async (req, user) =>
//                Response.json({ id: user.id }))
//   Fastify  → import { requireUser } from '@onelo/node/fastify'
//              fastify.get('/me', { preHandler: requireUser(onelo) },
//                async (req) => ({ id: req.oneloUser!.id }))
//   Hono     → import { requireUser } from '@onelo/node/hono'
//              app.use('/me', requireUser(onelo))
//              app.get('/me', (c) => c.json({ id: c.get('oneloUser').id }))
//   Any other (Koa, Nest, raw http, WebSocket, queue worker):
//              import { verifyToken } from '@onelo/node'
//              const user = await verifyToken(onelo, token)   // throws typed errors
//
// Error → status: OneloAuthInvalidToken / missing → 401 · OneloAuthForbidden /
//   failed gate → 403 · OneloAuthRateLimited (429) / OneloAuthUnavailable (5xx) → 503.
The handoff (frontend + backend): ① the client SDK signs the user in and gets a token → it sends Authorization: Bearer <token> on each request → ② requireUser on your server verifies it against Onelo before serving protected data. Never trust client-supplied identity — always verify server-side.

4Connect an existing sign-in

Onelo Auth is the default. A web app that already signs its users in with Supabase, Clerk or Firebase can connect that sign-in instead: set the app's Sign-in Mode to the provider, then give the JavaScript SDK a function that returns the provider's current token. The SDK exchanges the token for an Onelo session and keeps it where every module reads it, so Paywall, Features, Consent and the rest work unchanged.

  • The Onelo session lasts 30 minutes. After about 80% of that time the SDK asks your function for a fresh token and exchanges again, so the user never sees it lapse.
  • When your function returns no token, the Onelo session ends and onAuthStateChange reports signed out.
  • Onelo sign-in screens are never shown in this mode: loadAuthView() only returns the current session.
  • Onelo never receives your provider's secrets. Setup takes the issuer and its audience, and the SDK sends only the user's token.

Install and initialize @onelo/js as shown above, then add the block for your provider.

typescript — Supabase
// `supabase` is your existing Supabase client, `onelo` the instance above.
// The project must sign its tokens with an asymmetric key (ES256 or RS256);
// the legacy shared-secret (HS256) tokens are refused.

// getSession() refreshes the Supabase session when needed. null = signed out.
const supabaseToken = async () =>
  (await supabase.auth.getSession()).data.session?.access_token ?? null

// Fires once on start (INITIAL_SESSION), then on every sign-in and sign-out.
let supabaseUser: string | null | undefined
supabase.auth.onAuthStateChange((_event, session) => {
  const user = session?.user.id ?? null
  if (user === supabaseUser) return // a token refresh, not another user
  supabaseUser = user
  // Outside the callback: Supabase calls must not run inside it.
  setTimeout(() => {
    onelo.auth.useExternalToken(supabaseToken).catch((e) => console.error('[Onelo]', e))
  }, 0)
})
typescript — Clerk
// `clerk` is your loaded Clerk instance (window.Clerk, or useClerk() in
// React), `onelo` the instance above.

// getToken() answers from Clerk's cache and fetches a new token when the
// one-minute token has expired. null = signed out.
const clerkToken = async () => (await clerk.session?.getToken()) ?? null

// Fires once on start, then whenever the Clerk session changes.
let clerkUser: string | null | undefined
clerk.addListener(({ session }) => {
  if (session === undefined) return // Clerk is still loading
  const user = session?.user?.id ?? null
  if (user === clerkUser) return
  clerkUser = user
  onelo.auth.useExternalToken(clerkToken).catch((e) => console.error('[Onelo]', e))
})

// Clerk's session token carries no e-mail by default. To give Onelo the
// address, add `email` and `email_verified` to the session token in the Clerk
// dashboard (Sessions → Customize session token).
typescript — Firebase
import { getAuth, onAuthStateChanged } from 'firebase/auth'

// `onelo` is the instance above.
const firebaseAuth = getAuth()

// getIdToken() returns the current ID token and refreshes one that expires
// within five minutes. No current user = signed out.
const firebaseToken = async () => (await firebaseAuth.currentUser?.getIdToken()) ?? null

// Fires once on start, then on every sign-in and sign-out.
onAuthStateChanged(firebaseAuth, () => {
  onelo.auth.useExternalToken(firebaseToken).catch((e) => console.error('[Onelo]', e))
})
typescript — then, with any provider
// From here every module works as with Onelo Auth.
const session = await onelo.auth.getSession() // null = signed out at the provider
onelo.auth.onAuthStateChange((s) => {
  console.log(s ? `signed in as ${s.user.email}` : 'signed out')
})
if (await onelo.auth.isAllowedIn()) showApp()

The full guide covers the Sign-in Mode setting, each provider step by step, the Node.js server, the limits and every refusal code: Connect your own sign-in.

5Security

🔒Publishable vs secret keys

The client ships a publishable key (safe to embed); only your server holds the secret key. verifyToken / requireUser refuse to run with a publishable key.

🔒Token storage

Keychain (Apple), safeStorage (Electron), EncryptedSharedPreferences (Android) — never plain files or localStorage on native.

🔒Hosted flow

Passwords never touch your app. The one-time code is single-use, short-lived, and bound to your app — it is only delivered to your registered scheme (native) or validated origin (web). OAuth and custom-UI mobile/desktop flows additionally use PKCE.

🔒Server verification

Your backend re-checks every token against Onelo and maps failures to 401 (invalid), 403 (forbidden / failed gate), 503 (Onelo unreachable) — it never trusts client-supplied identity.

6Reference & raw contract

Not using an SDK? Verify a token with a single request — the same endpoint every server SDK calls. Send the user's token as a bearer, and your secret key in the header.

GET https://api.onelo.tools/api/sdk/auth/user
# Authorization: Bearer <user access token>
# X-Onelo-Secret-Key: <your secret key>
# 200 → { id, email, created_at, metadata }
# hosted-auth tokens also include entitlement: "active" | "none" (paid-access status)
# 401 → reject the request

Use your Onelo API base URL (shown on the dashboard SDK tab) — the endpoint lives on the API host, not on your dashboard domain.

Replace onelo_pk_live_YOUR_KEY and the API URL with the values from your dashboard → SDK tab.

Authentication — Onelo Docs