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
| Protection | What the server enforces |
|---|---|
| PKCE required | Public clients must send a code_challenge. Only S256 is accepted — plain or a missing method is rejected with HTTP 400. |
| No secret | Public clients cannot have or regenerate a client_secret, so nothing sensitive ships in your JavaScript bundle. |
| Exact redirect URIs | The redirect_uri must match a registered URI exactly. Live clients require HTTPS on a public hostname. |
| Restricted CORS | Only 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 tokens | Access tokens expire after 1 hour. Each refresh issues a new refresh token and revokes the previous one. |
Flow
- The SPA creates a random
code_verifierandstate, keeps them insessionStorage, and redirects to/oauth/authorizewithcode_challenge = BASE64URL(SHA-256(code_verifier)). - The user signs in (password, Google, passkey, MFA) and approves access.
- WamISSO redirects back with
?code=…&state=…. - The SPA checks
state, then POSTs thecodeand the originalcode_verifierto/oauth/token(no secret). - The SPA calls
/api/userwith 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-scopedsessionStorageso a reload doesn't force a new sign-in. - Always verify
stateand generate a freshcode_verifierfor 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/logouton 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
| Symptom | Cause |
|---|---|
| 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/token | The SPA's origin doesn't match a redirect URI on an active public client (scheme, host, and port must match). |
invalid_grant on token exchange | Wrong code_verifier, reused/expired code, or a redirect_uri that differs from the authorize request. |