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.