Skip to content
BolderBolder Developers
Esc
↑↓navigate↵open⌘Jpreview
On this page

Sign in users from a browser app

Log Bolder users into a single-page app with the authorization code flow and PKCE, keep the session alive with refresh tokens and prompt=none, and call the API from the browser.

Goal: a page with no backend of its own, such as a staff dashboard or a dealer portal, where people sign in with their Bolder account and the page calls the v2 API with their token. Nothing in the browser can keep a secret, so the app uses PKCE instead of a client_secret. Each user only gets the scopes their role carries.

If your app has a server, the code grant with a secret in Build an app for other merchants is simpler to operate.

Register the application

Go to start.onbolder.com/dev/credentials, create an application and set its redirect URI to the page that will receive the login, for example https://yourapp.com/callback. You only need the CLIENT_ID; the secret is never used.

The token endpoint accepts browser requests from the origin of that redirect URI only (https://yourapp.com). For local development, register an http://localhost URI: any port works.

Send the user to sign in

Generate a random code_verifier, keep it for the callback, and send its SHA-256 hash as the code_challenge. The state protects the callback from forged redirects.

const AUTH = 'https://auth.onbolder.com'
const CLIENT_ID = 'YOUR_CLIENT_ID'
const REDIRECT_URI = 'https://yourapp.com/callback'
const SCOPES = 'orders.read,products.read'

const base64url = (bytes) =>
  btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
const randomString = (bytes) => base64url(crypto.getRandomValues(new Uint8Array(bytes)))

async function challengeFor(verifier) {
  const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
  return base64url(new Uint8Array(hash))
}

export async function login({ silent = false } = {}) {
  const verifier = randomString(32)
  const state = randomString(16)
  sessionStorage.setItem('bolder_pkce', JSON.stringify({ verifier, state }))

  const query = new URLSearchParams({
    client_id: CLIENT_ID,
    response_type: 'code',
    redirect_uri: REDIRECT_URI,
    scope: SCOPES,
    state,
    code_challenge: await challengeFor(verifier),
    code_challenge_method: 'S256'
  })
  if (silent) query.set('prompt', 'none')
  location.assign(`${AUTH}/oauth/authorize?${query}`)
}

Ask only for scopes the users’ role has: a request for any other scope is refused with invalid_scope, not trimmed. A vendedor, for example, gets orders.read_own and orders.write_own (only the orders of their own customers), never orders.read. The table is in the API reference.

Exchange the code on the callback page

Bolder redirects to your redirect URI with code and state. Check the state, then trade the code for tokens with the verifier, without any secret:

async function tokenRequest(params) {
  const res = await fetch(`${AUTH}/oauth/token`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams(params)
  })
  const body = await res.json()
  if (!res.ok) throw new Error(body.error) // invalid_grant, invalid_scope...
  return body
}

function save({ access_token, refresh_token, expires_in }) {
  localStorage.setItem('bolder_tokens', JSON.stringify({
    access_token,
    refresh_token,
    expires_at: Date.now() + expires_in * 1000
  }))
}

export async function handleCallback() {
  const query = new URLSearchParams(location.search)
  const saved = JSON.parse(sessionStorage.getItem('bolder_pkce') || 'null')
  sessionStorage.removeItem('bolder_pkce')

  if (query.get('error')) throw new Error(query.get('error')) // access_denied, login_required, consent_required
  if (!saved || query.get('state') !== saved.state) throw new Error('invalid_state')

  save(await tokenRequest({
    grant_type: 'authorization_code',
    client_id: CLIENT_ID,
    redirect_uri: REDIRECT_URI,
    code: query.get('code'),
    code_verifier: saved.verifier
  }))
}

The response has access_token, expires_in (3600: access tokens last one hour), scopes and, if your app has refresh tokens enabled, refresh_token. Anything in localStorage can be read by every script on your page: don’t load scripts you don’t trust there.

Keep the session alive

Refresh tokens are off by default for applications. Write to help@onbolder.com with your CLIENT_ID to enable them. Once enabled, the code exchange also returns a refresh_token valid for 24 hours, and you renew with just the client_id. Each refresh returns a new refresh token, so the session lasts while the user is active.

When there is no refresh token, or it has expired, send the user back to /oauth/authorize with prompt=none. If they are still signed in to Bolder and have already authorized your app, the code comes back without any page shown. If not, the callback receives error=login_required or error=consent_required: show a “Sign in” button that calls login().

export async function accessToken() {
  const tokens = JSON.parse(localStorage.getItem('bolder_tokens') || 'null')
  if (tokens && tokens.expires_at - Date.now() > 60_000) return tokens.access_token

  if (tokens?.refresh_token) {
    try {
      const fresh = await tokenRequest({
        grant_type: 'refresh_token',
        client_id: CLIENT_ID,
        refresh_token: tokens.refresh_token
      })
      save(fresh)
      return fresh.access_token
    } catch {
      localStorage.removeItem('bolder_tokens') // expired, revoked, or the user's role changed
    }
  }
  await login({ silent: true }) // leaves the page
}

Every refresh checks the user again: someone whose role was removed, downgraded or disabled gets invalid_scope, and someone who revoked your app gets invalid_grant. An access token already issued keeps working until it expires.

Call the API

The API answers CORS requests from any origin, so the page calls it directly with the token in the Authorization header:

const res = await fetch('https://api.onbolder.com/v2/orders?per_page=10', {
  headers: { Authorization: `Bearer ${await accessToken()}` }
})
const { _embedded } = await res.json()

If a call answers 401, the token has expired or been revoked: get a new one with accessToken() and retry once.

Sign out

Delete the stored tokens. The user stays signed in to Bolder itself, so a later prompt=none login would succeed without asking; to end that session too, send them to https://auth.onbolder.com/oauth/logout, which ends on the Bolder sign-in page.

export function logout() {
  localStorage.removeItem('bolder_tokens')
}

Next

Was this page helpful?