WamISSO Documentation

Browser SPA Integration (PKCE)

Sign users in to a React, Vue, Angular, or plain JavaScript single-page app directly from the browser — no backend and no client secret — using the Authorization Code flow with PKCE.

Before you start: In the developer portal, register an application with Client type → Public — browser SPA or mobile app. Public clients get a client_id only; no secret is issued. Add your SPA's callback URL (for example https://app.example.gov.ph/callback) as a redirect URI.

How WamISSO secures browser clients

ProtectionWhat the server enforces
PKCE requiredPublic clients must send a code_challenge. Only S256 is accepted — plain or a missing method is rejected with HTTP 400.
No secretPublic clients cannot have or regenerate a client_secret, so nothing sensitive ships in your JavaScript bundle.
Exact redirect URIsThe redirect_uri must match a registered URI exactly. Live clients require HTTPS on a public hostname.
Restricted CORSOnly the origins of an active public client's redirect URIs may call /oauth/token and /api/* from a browser. Cookies are never shared cross-origin (Bearer tokens only).
Short-lived, rotating tokensAccess tokens expire after 1 hour. Each refresh issues a new refresh token and revokes the previous one.

Flow

  1. The SPA creates a random code_verifier and state, keeps them in sessionStorage, and redirects to /oauth/authorize with code_challenge = BASE64URL(SHA-256(code_verifier)).
  2. The user signs in (password, Google, passkey, MFA) and approves access.
  3. WamISSO redirects back with ?code=…&state=….
  4. The SPA checks state, then POSTs the code and the original code_verifier to /oauth/token (no secret).
  5. The SPA calls /api/user with the access token.

Configuration

# .env (Vite) — these values are public; never put a client secret here
VITE_SSO_BASE_URL=https://wamisso.rendovations.com
VITE_SSO_CLIENT_ID=your-public-client-id
VITE_SSO_REDIRECT_URI=http://localhost:5173/callback

sso.js — drop-in client (no dependencies)

const SSO_BASE_URL = import.meta.env.VITE_SSO_BASE_URL;
const CLIENT_ID = import.meta.env.VITE_SSO_CLIENT_ID;
const REDIRECT_URI = import.meta.env.VITE_SSO_REDIRECT_URI;
const PKCE_KEY = 'wamisso.pkce';
const REFRESH_KEY = 'wamisso.refresh_token';

// Access token lives in memory only, so injected scripts can't read it from storage.
let accessToken = null;
let accessTokenExpiresAt = 0;

function base64Url(bytes) {
  return btoa(String.fromCharCode(...new Uint8Array(bytes)))
    .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

function randomString(byteLength = 32) {
  return base64Url(crypto.getRandomValues(new Uint8Array(byteLength)));
}

async function s256(verifier) {
  return base64Url(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)));
}

export async function login(returnTo = window.location.pathname) {
  const verifier = randomString(64);
  const state = randomString(32);
  sessionStorage.setItem(PKCE_KEY, JSON.stringify({ verifier, state, returnTo }));

  const params = new URLSearchParams({
    client_id: CLIENT_ID,
    redirect_uri: REDIRECT_URI,
    response_type: 'code',
    scope: 'openid profile email',
    state,
    code_challenge: await s256(verifier),
    code_challenge_method: 'S256',
  });

  window.location.assign(`${SSO_BASE_URL}/oauth/authorize?${params}`);
}

async function requestToken(body) {
  const response = await fetch(`${SSO_BASE_URL}/oauth/token`, {
    method: 'POST',
    headers: { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({ client_id: CLIENT_ID, ...body }),
  });

  if (!response.ok) throw new Error('Token request failed');

  const tokens = await response.json();
  accessToken = tokens.access_token;
  accessTokenExpiresAt = Date.now() + (tokens.expires_in - 30) * 1000;
  if (tokens.refresh_token) sessionStorage.setItem(REFRESH_KEY, tokens.refresh_token);

  return tokens;
}

// Call this on your /callback route. Returns the path to navigate to.
export async function handleCallback() {
  const url = new URL(window.location.href);
  const saved = JSON.parse(sessionStorage.getItem(PKCE_KEY) || 'null');
  sessionStorage.removeItem(PKCE_KEY);

  // Remove ?code=&state= from the address bar and history.
  window.history.replaceState({}, document.title, url.pathname);

  if (url.searchParams.get('error')) throw new Error(url.searchParams.get('error_description') || 'Sign-in was cancelled');
  if (!saved || url.searchParams.get('state') !== saved.state) throw new Error('Invalid OAuth state');

  await requestToken({
    grant_type: 'authorization_code',
    redirect_uri: REDIRECT_URI,
    code: url.searchParams.get('code'),
    code_verifier: saved.verifier,
  });

  return saved.returnTo || '/';
}

export async function getAccessToken() {
  if (accessToken && Date.now() < accessTokenExpiresAt) return accessToken;

  const refreshToken = sessionStorage.getItem(REFRESH_KEY);
  if (!refreshToken) return null;

  try {
    await requestToken({ grant_type: 'refresh_token', refresh_token: refreshToken, scope: 'openid profile email' });
    return accessToken;
  } catch {
    sessionStorage.removeItem(REFRESH_KEY);
    return null;
  }
}

export async function fetchUser() {
  const token = await getAccessToken();
  if (!token) return null;

  const response = await fetch(`${SSO_BASE_URL}/api/user`, {
    headers: { Accept: 'application/json', Authorization: `Bearer ${token}` },
  });

  return response.ok ? response.json() : null;
}

export async function logout() {
  const token = accessToken;
  accessToken = null;
  sessionStorage.removeItem(REFRESH_KEY);

  if (token) {
    await fetch(`${SSO_BASE_URL}/api/sso/logout`, {
      method: 'POST',
      headers: { Accept: 'application/json', Authorization: `Bearer ${token}` },
    }).catch(() => {});
  }
}

Using it in your app

// main.js — before mounting your router
import { handleCallback, fetchUser, login } from './sso';

if (window.location.pathname === '/callback') {
  const next = await handleCallback();
  window.history.replaceState({}, '', next);
}

const user = await fetchUser();
if (!user) {
  await login();          // redirects to WamISSO
} else {
  console.log('Signed in as', user.email);
  // mount React / Vue / Angular app here
}

When calling your own API, send the WamISSO access token as a Bearer token and have the API validate it by calling https://wamisso.rendovations.com/api/user (cache the result briefly).

Security checklist

  • Never use a confidential client (or any client_secret) in browser code.
  • Keep the access token in memory. Never store tokens in localStorage; the refresh token above is kept in tab-scoped sessionStorage so a reload doesn't force a new sign-in.
  • Always verify state and generate a fresh code_verifier for every sign-in.
  • Serve the SPA over HTTPS with a strict Content-Security-Policy (for example script-src 'self') to limit XSS — XSS is the main way tokens leak from SPAs.
  • Register exact redirect URIs. Use separate testing (localhost) and live (HTTPS) clients.
  • Call /api/sso/logout on sign-out and clear local state even if the call fails.
  • For high-sensitivity systems, prefer a backend-for-frontend (see Vue + Laravel API) so tokens never reach the browser.

Troubleshooting

SymptomCause
HTTP 400 “code_challenge_method must be S256”You sent plain or omitted code_challenge_method.
invalid_request “Code challenge must be provided for public clients”The authorize URL is missing code_challenge.
CORS error on /oauth/tokenThe SPA's origin doesn't match a redirect URI on an active public client (scheme, host, and port must match).
invalid_grant on token exchangeWrong code_verifier, reused/expired code, or a redirect_uri that differs from the authorize request.