Skip to content

Plain browser

One client instance, one callback route, and an access token for every request your application makes.

For a single-page application with no module of ours: Vite and vanilla TypeScript, React, Svelte, Solid, or a framework we have never heard of. @kleora-io/browser is the authorization-code-with-PKCE client and knows nothing about your framework.

npm install @kleora-io/browser

No runtime dependencies, ES modules only.

1. Create the client once

One instance for the whole application, in its own module. Creating a second one is how two halves of a page end up with two different ideas of who is signed in.

// src/kleora.ts
import { createKleora } from '@kleora-io/browser'

export const kleora = createKleora({
  issuer: import.meta.env.VITE_KLEORA_ISSUER,
  clientId: import.meta.env.VITE_KLEORA_CLIENT_ID,
  redirectUri: location.origin + '/callback',
  storage: 'session',
})

issuer, clientId and redirectUri are the three required options; the rest have defaults. scope defaults to openid profile email offline_access — keep offline_access or no refresh token is issued and nothing can be renewed silently.

2. Handle the callback

The issuer sends the browser back to /callback with an authorization code. Redeem it there, then put the person back where they started:

import { kleora } from './kleora'

if (location.pathname === '/callback') {
  const { appState } = await kleora.handleRedirectCallback()
  const returnTo = (appState as { returnTo?: string })?.returnTo ?? '/'
  history.replaceState(null, '', returnTo)
}

handleRedirectCallback() validates state and iss, redeems the code with the PKCE verifier, validates the ID token against the issuer's key set, stores the tokens and strips the credentials out of the address bar.

Your dev server has to serve the application's HTML for /callback as well as for /. Vite's dev server does that for unknown paths already; a plain static file server does not, and answers 404 to the one navigation that matters.

3. Sign in, and know when you are signed in

if (!(await kleora.checkSession())) {
  await kleora.loginWithRedirect({ returnTo: location.pathname })
}

const user = kleora.getUser() // sub, email, name, tenant, tenant_slug…

checkSession() tries a fresh cached token first, then a stored refresh token, then a silent prompt=none round trip in a hidden frame. returnTo is carried through the redirect and handed back as appState.returnTo — which is what step 2 reads.

To react to sign-in, sign-out and refresh rather than polling:

const unsubscribe = kleora.onAuthChange(({ user, isAuthenticated }) => {
  render(isAuthenticated ? user : null)
})

There is also kleora.hasLikelySession(), which answers whether this browser completed a sign-in that was never followed by a sign-out. It is a render hint — it lets you show your shell instead of a sign-in flash while verification runs. It is never an authorisation decision, and never proof of a live session; only checkSession() or a successful API call is that.

4. Call your own API

const token = await kleora.getAccessToken()

await fetch('/api/invoices', {
  headers: { Authorization: `Bearer ${token}` },
})

getAccessToken() returns the cached token until it is within 30 seconds of expiry, then refreshes; concurrent callers share one refresh. Refresh tokens rotate on every use. If your API answers 401 for a token this client still believes in, retry once with getAccessToken({ force: true }) — that discards the cached token instead of re-sending the rejected one.

Verifying that token is the Express page — or verify_access_token if your API is Python.

5. Sign out

await kleora.logout({ returnTo: location.origin + '/' })

returnTo is required and must be one of the client's registered post-logout URIs, matched exactly. Local state is cleared before anything is fetched, so a sign-out completes locally even when the issuer cannot be reached.