@antzsoft/wso2-auth-web
v1.1.16
Published
Framework-agnostic WSO2 IS auth client — login, logout, token refresh, change password. Works with React, Vue, Next.js, or plain JS.
Downloads
2,144
Readme
@antzsoft/wso2-auth-web
Framework-agnostic OAuth2 / PKCE client for WSO2 Identity Server 7.x.
Handles login, logout, token refresh, change password (with OTP), and auto-logout on session expiry. Works with React, Vue 3, Next.js (with or without proxy routes), and plain JavaScript/TypeScript.
What's new in v1.1.15+
decodeToken() — decode any JWT access token locally:
A new named export decodeToken decodes any JWT string without a network call. Useful for displaying access token claims (sub, roles, tenant, exp, iss, etc.) on a dashboard:
import { decodeToken } from "@antzsoft/wso2-auth-web";
const token = await client.getAccessToken();
const claims = decodeToken(token!);
// { sub: "...", roles: [...], tenant: "dev", exp: 1745612800, ... }Returns Record<string, unknown> | null. Returns null if the string is not a valid JWT or decoding fails.
What's new in v1.1.10+
logout() is now fully fetch-based — no browser redirect to WSO2:
Previously logout() redirected the browser to WSO2's /oidc/logout endpoint, which could show a "Are you sure you want to log out?" confirmation page. Starting in v1.1.10, logout is handled entirely via fetch:
POST /oauth2/revoke— revokes the refresh token server-sidePOST /oidc/logoutwithcredentials: 'include'— terminates the WSO2 SSO session using the browser's session cookie, with no browser redirect and no confirmation pageprompt=loginflag — forces the login page on nextlogin()call, even if the WSO2 session cookie is still alive
logout() is now async and returns Promise<void>:
Update your call sites if you need to await it:
// Before (v1.1.9 and earlier)
logout: () => void
// After (v1.1.10+)
logout: () => Promise<void>React adapter returns accessToken:
useAntzAuth() now returns accessToken: string | null — a reactive state value that updates automatically after every background refresh. No need to call getAccessToken() just to display the current token.
Vue adapter returns status and accessToken:
useAntzAuth() now returns status (reactive ref matching the React adapter) and accessToken in addition to the existing fields.
React 18 Strict Mode protection:
useAntzCallback and useAntzAuth's session-restore effect are both protected by useRef run-once guards — token exchange and session restore fire exactly once per navigation, even in Strict Mode's double-mount development behavior.
Contents
- What's new in v1.1.10+
- What's in the package
- Installation
- Configuration
- Core API
- Storage Adapters
- Error Types
- Framework Adapters
- Integration Guides
- CORS and Proxy Explained
- Auto-Logout on Session Expiry
- Change Password Flow
What's in the package
| Export | Description |
|--------|-------------|
| AntzAuthClient | Core client class — works in any JS environment |
| useAntzAuth | React hook (from @antzsoft/wso2-auth-web/react) |
| useAntzCallback | React callback hook (from @antzsoft/wso2-auth-web/react) |
| useAntzAuth | Vue 3 composable (from @antzsoft/wso2-auth-web/vue) |
| useAntzCallback | Vue 3 callback composable (from @antzsoft/wso2-auth-web/vue) |
| SessionStorageAdapter | Default token storage (cleared on tab close) |
| LocalStorageAdapter | Persistent token storage (survives page reload) |
| MemoryStorageAdapter | In-memory storage (SSR / testing) |
| decodeToken | Decodes any JWT string locally — no network call. Returns all payload claims |
| Error classes | Typed errors for every failure scenario |
Installation
# npm
npm install @antzsoft/wso2-auth-web
# yarn
yarn add @antzsoft/wso2-auth-web
# pnpm
pnpm add @antzsoft/wso2-auth-webFor React projects, react >= 18 must be installed (peer dependency).
For Vue projects, vue >= 3 must be installed (peer dependency).
Configuration
import { AntzAuthClient } from "@antzsoft/wso2-auth-web";
const client = new AntzAuthClient({
baseUrl: "https://auth.antzsystems.com", // WSO2 IS base URL, no trailing slash
clientId: "your-client-id", // OAuth2 client_id from WSO2 Console
redirectUri: "https://yourapp.com/callback", // Must be registered in WSO2 Console
tenant: "dev", // Tenant: "dev" | "uat" | "prod" — omit for carbon.super
scopes: ["openid", "profile", "email", "roles"], // OAuth2 scopes
proxyUrl: "/api/auth/change-password", // Optional — see CORS section below
storage: new SessionStorageAdapter(), // Optional — default: SessionStorageAdapter
postLogoutRedirectUri: "https://yourapp.com", // Optional — default: redirectUri
});Config options
| Option | Type | Required | Description |
|--------|------|----------|-------------|
| baseUrl | string | Yes | WSO2 IS base URL (e.g. https://auth.antzsystems.com) |
| clientId | string | Yes | OAuth2 client_id registered in WSO2 Console |
| redirectUri | string | Yes | Callback URL — must exactly match WSO2 Console registration |
| tenant | string | No | Tenant domain (dev, uat, prod). Omit for root org (carbon.super) |
| scopes | string[] | No | OAuth2 scopes. Default: ["openid", "profile", "email"] |
| proxyUrl | string | No | Base URL for same-origin proxy routes. Required when WSO2 CORS is not configured. See CORS section |
| storage | StorageAdapter | No | Token storage adapter. Default: SessionStorageAdapter |
| postLogoutRedirectUri | string | No | Where the browser navigates after logout. Default: redirectUri. |
| refreshBufferSeconds | number | No | Seconds before access token expiry to proactively refresh. Default: 60 |
Core API
login()
Starts the PKCE authorization code flow. Generates verifier + challenge, saves them to storage, then redirects the browser to WSO2's /authorize endpoint.
await client.login();
// With custom scopes
await client.login(["openid", "profile", "email", "roles"]);This method redirects the browser — it never returns.
handleCallback()
Call this on your redirect/callback page. Reads code and state from the URL, validates state (CSRF protection), and exchanges the code for tokens.
const tokens = await client.handleCallback();
// tokens: { access_token, refresh_token, id_token, expires_in, ... }
// Or pass the URL explicitly (useful in SSR or testing)
const tokens = await client.handleCallback("https://yourapp.com/callback?code=...&state=...");Throws:
AntzAuthError— state mismatch (CSRF), missing code, or authorization error from WSO2AntzTokenError— missing PKCE verifier (session lost) or token exchange failed
getAccessToken()
Returns the current access token. Silently refreshes it if it is expiring within refreshBufferSeconds (default: 60 seconds). Returns null if not authenticated or if the refresh token has expired.
const token = await client.getAccessToken();
if (!token) {
// Not authenticated or session expired — redirect to login
}refreshTokens()
Explicitly refreshes tokens using the stored refresh token. Returns null and clears the session if the refresh token is missing or expired.
const tokens = await client.refreshTokens();
// tokens: TokenSet | nullgetUser()
Decodes user claims from the stored id_token locally — no network call.
const user = client.getUser();
// {
// sub: "9f3eed57-...",
// email: "[email protected]",
// name: "John Doe",
// given_name: "John",
// family_name: "Doe",
// username: "dev_john",
// tenant: "dev",
// roles: ["admin", "user"]
// }Returns null if not authenticated.
isAuthenticated()
Synchronous check — returns true if an access token exists and has not expired.
if (client.isAuthenticated()) {
// show dashboard
} else {
// show login
}Note: this checks expiry locally. It does not validate the token with WSO2. Use
getAccessToken()for a check that also attempts a silent refresh.
logout()
Revokes tokens, terminates the WSO2 SSO session server-side, clears all local storage, and navigates to postLogoutRedirectUri. Everything happens via fetch — there is no browser redirect to WSO2, no confirmation page.
await client.logout();What it does, in order:
POST /oauth2/revoke— revokes the refresh token (invalidates it on the WSO2 server)POST /oidc/logout— terminates the WSO2 SSO session usingid_token_hint(sends the browser's session cookie viacredentials: 'include', so WSO2 can find and kill the session)- Sets a
prompt=loginflag in storage so the nextlogin()call forces WSO2 to show the login page (prevents silent re-login via surviving SSO session) - Clears all tokens and auth state from storage
- Navigates to
postLogoutRedirectUri(defaults toredirectUri) viawindow.location.href
Both network calls are fire-and-forget — a failure in either does not prevent local logout or navigation.
Idempotent — safe to call from auto-logout and manual logout:
logout() is guarded internally against concurrent calls. If status transitions to 'unauthenticated' (triggering auto-logout) at the same time the user clicks "Sign out" (triggering manual logout), only one logout sequence runs. You do not need any debounce logic in your components.
// Manual logout button
<button onClick={() => logout()}>Sign out</button>
// Auto-logout when session expires (React)
useEffect(() => {
if (status === "unauthenticated") logout();
}, [status, logout]);
// Both paths call the same logout() — identical behaviorprompt=login after logout:
After logout(), the next call to login() automatically appends prompt=login to the WSO2 /authorize URL. This forces WSO2 to show the login page even if the SSO session is still alive (e.g. when the user's refresh token expired but the browser session cookie had not yet timed out). The flag is consumed once and then removed.
postLogoutRedirectUri:
Controls where the browser navigates after tokens are cleared. Defaults to redirectUri.
const client = new AntzAuthClient({
redirectUri: "http://localhost:3000/callback",
postLogoutRedirectUri: "http://localhost:3000", // optional, defaults to redirectUri
});sendOtp()
Requests WSO2 to send a one-time password to the user's registered mobile/email before a password change. Only needed when OTP is enabled for the tenant/app in WSO2 Console.
const { otpRequired, message } = await client.sendOtp();
if (!otpRequired) {
// OTP is disabled for this app — call changePassword() directly without an OTP
}
if (otpRequired) {
// OTP was sent — show OTP input to the user
// message may contain a hint like "OTP sent to +91XXXXXXXX90"
}Returns: { otpRequired: boolean, message?: string }
Throws:
AntzSessionExpiredError— access token expiredAntzApiError— no contact info on account or send failure
When proxyUrl is set:
Calls POST {proxyUrl}/send-otp (your same-origin proxy).
When not set, calls WSO2 directly (requires CORS to be configured).
changePassword()
Changes the logged-in user's password.
// Without OTP (when OTP is disabled for the app)
await client.changePassword(currentPassword, newPassword);
// With OTP (when OTP is enabled — call sendOtp() first)
await client.changePassword(currentPassword, newPassword, otp);Throws:
| Error | When |
|-------|------|
| AntzInvalidCredentialsError | currentPassword is wrong |
| AntzInvalidOtpError | OTP code is wrong |
| AntzOtpExpiredError | OTP has passed its 5-minute TTL |
| AntzOtpRequiredError | OTP enabled but not provided, or sendOtp() was not called |
| AntzOtpMaxAttemptsError | 3 wrong OTP codes — OTP invalidated, call sendOtp() again |
| AntzPasswordPolicyError | New password fails WSO2 policy (complexity, history) |
| AntzSessionExpiredError | Access token expired |
| AntzApiError | Other WSO2 error |
When proxyUrl is set:
Calls POST {proxyUrl} (your same-origin proxy).
When not set, calls WSO2 directly (requires CORS to be configured).
decodeToken()
Decodes a JWT string locally — no network call, no signature verification. Returns all payload claims as a plain object, or null if the input is not a valid JWT.
import { decodeToken } from "@antzsoft/wso2-auth-web";
const token = await client.getAccessToken();
if (token) {
const claims = decodeToken(token);
// {
// sub: "9f3eed57-...",
// roles: ["admin", "user"],
// tenant: "dev",
// exp: 1745612800,
// iss: "https://auth.antzsystems.com/t/dev/oauth2/token",
// ...
// }
}This is a standalone utility — it is not a method on AntzAuthClient. Import it directly from the package root. Useful for displaying token debug info in dashboards without making any API calls.
forgotPassword()
Redirects to WSO2 My Account portal for self-service password reset.
client.forgotPassword(); // opens in new tab (default)
client.forgotPassword(false); // redirects current tabapiFetch()
Makes an authenticated fetch to your backend API. Automatically attaches the Bearer token and silently refreshes if needed. Throws AntzSessionExpiredError on 401.
const data = await client.apiFetch<{ name: string }>("/api/profile");
// With custom options
const result = await client.apiFetch("/api/data", {
method: "POST",
body: JSON.stringify({ key: "value" }),
});Storage Adapters
The package includes three built-in adapters and supports custom ones.
SessionStorageAdapter (default)
Tokens are stored in sessionStorage — cleared when the tab is closed.
import { AntzAuthClient, SessionStorageAdapter } from "@antzsoft/wso2-auth-web";
const client = new AntzAuthClient({
// ...
storage: new SessionStorageAdapter(), // this is the default
});LocalStorageAdapter
Tokens survive tab close and page reload. Higher XSS risk than sessionStorage.
import { AntzAuthClient, LocalStorageAdapter } from "@antzsoft/wso2-auth-web";
const client = new AntzAuthClient({
// ...
storage: new LocalStorageAdapter(),
});MemoryStorageAdapter
Tokens stored in memory — lost on page reload. Useful for SSR environments where sessionStorage/localStorage are not available.
import { AntzAuthClient, MemoryStorageAdapter } from "@antzsoft/wso2-auth-web";
const client = new AntzAuthClient({
// ...
storage: new MemoryStorageAdapter(),
});Custom Adapter
Implement the StorageAdapter interface to use cookies, Redis, or any other store:
import type { StorageAdapter } from "@antzsoft/wso2-auth-web";
class CookieStorageAdapter implements StorageAdapter {
get(key: string): string | null { /* ... */ }
set(key: string, value: string): void { /* ... */ }
remove(key: string): void { /* ... */ }
clear(): void { /* ... */ }
}Error Types
All errors extend AntzAuthError which extends Error.
| Class | When thrown |
|-------|-------------|
| AntzAuthError | Base class — generic auth errors, CSRF, config errors |
| AntzTokenError | Token exchange or refresh failed |
| AntzApiError | WSO2 API returned a non-2xx response. Has .status (number) and .body (string) |
| AntzSessionExpiredError | Refresh token expired — user must log in again |
| AntzInvalidCredentialsError | Wrong current password in changePassword() |
| AntzInvalidOtpError | Wrong OTP code |
| AntzOtpExpiredError | OTP TTL (5 min) elapsed |
| AntzOtpRequiredError | OTP enabled but not provided |
| AntzOtpMaxAttemptsError | 3 consecutive wrong OTP codes |
| AntzPasswordPolicyError | New password fails WSO2 complexity/history policy |
import {
AntzInvalidCredentialsError,
AntzInvalidOtpError,
AntzOtpExpiredError,
AntzOtpRequiredError,
AntzOtpMaxAttemptsError,
AntzPasswordPolicyError,
AntzSessionExpiredError,
AntzApiError,
} from "@antzsoft/wso2-auth-web";
try {
await client.changePassword(current, newPassword, otp);
} catch (err) {
if (err instanceof AntzInvalidCredentialsError) { /* wrong current password */ }
if (err instanceof AntzInvalidOtpError) { /* wrong OTP */ }
if (err instanceof AntzOtpExpiredError) { /* OTP expired */ }
if (err instanceof AntzOtpRequiredError) { /* need to call sendOtp() first */ }
if (err instanceof AntzOtpMaxAttemptsError) { /* too many wrong attempts */ }
if (err instanceof AntzPasswordPolicyError) { /* err.message has policy detail */ }
if (err instanceof AntzSessionExpiredError) { /* redirect to login */ }
if (err instanceof AntzApiError) { /* err.status, err.body */ }
}Framework Adapters
React Adapter
Import from @antzsoft/wso2-auth-web/react.
useAntzAuth(client, options?)
import { useAntzAuth } from "@antzsoft/wso2-auth-web/react";
const {
status, // 'idle' | 'loading' | 'authenticated' | 'unauthenticated'
isAuthenticated, // boolean — shorthand for status === 'authenticated'
isLoading, // boolean — true while status is 'idle' or 'loading'
user, // UserClaims | null
accessToken, // string | null — updates automatically after every background refresh
error, // Error | null
login, // (scopes?: string[]) => Promise<void>
logout, // () => Promise<void>
getAccessToken, // () => Promise<string | null>
sendOtp, // () => Promise<{ otpRequired: boolean; message?: string }>
changePassword, // (current, newPwd, otp?) => Promise<void>
forgotPassword, // (newTab?: boolean) => void
} = useAntzAuth(client);Options:
| Option | Type | Description |
|--------|------|-------------|
| onSessionExpired | () => void | Optional callback when session expires. Alternative to watching status. |
Token refresh — fully automatic
The hook manages token refresh with three mechanisms (matching the RN package):
- Session restore on mount — checks stored tokens on load, silently refreshes if expiring soon
- Proactive timer — fires
refreshBufferSecondsbefore access token expiry, silently refreshes in the background - Tab visibility check — fires when the user switches back to the tab after it was hidden, in case the token expired while the tab was in the background
Reacting to session expiry — recommended: watch status
The cleanest way to handle session expiry is to watch the reactive status field in your router or a root layout component. When the refresh token expires, status automatically transitions to 'unauthenticated':
// Root layout or router guard
import { useAntzAuth } from "@antzsoft/wso2-auth-web/react";
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import client from "../auth";
export function AuthGuard({ children }: { children: React.ReactNode }) {
const { status } = useAntzAuth(client);
const navigate = useNavigate();
// Show loading screen while session is being restored
if (status === "idle" || status === "loading") {
return <div>Loading…</div>;
}
// Redirect to login if not authenticated
if (status === "unauthenticated") {
navigate("/login", { replace: true });
return null;
}
return <>{children}</>;
}This handles all four cases automatically:
| status | Cause | What to show |
|---|---|---|
| idle | Before session restore runs | Loading screen |
| loading | Session restore in progress | Loading screen |
| authenticated | Valid tokens | App content |
| unauthenticated | Not logged in, logout, or refresh token expired/revoked | Login page |
Alternative: onSessionExpired callback
If you prefer a callback over watching status (e.g. in a deeply nested component), you can pass onSessionExpired:
useAntzAuth(client, {
onSessionExpired: () => router.replace("/login"),
});Both approaches work — use whichever fits your routing setup.
useAntzCallback(client, onSuccess, onError?)
Use this on your /callback page component.
import { useAntzCallback } from "@antzsoft/wso2-auth-web/react";
const { isLoading, error } = useAntzCallback(
client,
() => router.push("/dashboard"), // onSuccess
(err) => router.push(`/?error=${err.message}`) // onError (optional)
);React 18 Strict Mode: In development, React mounts components twice.
useAntzCallbackis protected by auseRefrun-once guard — the token exchange fires only once per navigation, even in Strict Mode. Similarly,useAntzAuth's session-restoreuseEffectuses the same guard to prevent double refresh calls on mount.
Vue 3 Adapter
Import from @antzsoft/wso2-auth-web/vue.
useAntzAuth(client, options?)
import { useAntzAuth } from "@antzsoft/wso2-auth-web/vue";
const {
status, // Readonly<Ref<'idle' | 'loading' | 'authenticated' | 'unauthenticated'>>
isAuthenticated, // Readonly<Ref<boolean>>
isLoading, // Readonly<Ref<boolean>>
user, // Readonly<Ref<UserClaims | null>>
accessToken, // Readonly<Ref<string | null>> — updates after every background refresh
error, // Readonly<Ref<Error | null>>
login, // (scopes?: string[]) => Promise<void>
logout, // () => Promise<void>
getAccessToken, // () => Promise<string | null>
sendOtp, // () => Promise<{ otpRequired: boolean; message?: string }>
changePassword, // (current, newPwd, otp?) => Promise<void>
forgotPassword, // (newTab?: boolean) => void
} = useAntzAuth(client, {
onSessionExpired: () => router.replace("/login"),
});All reactive state is returned as readonly refs — use .value to access them in <script setup> or the Options API, and they are automatically unwrapped in templates.
useAntzCallback(client, onSuccess, onError?)
import { useAntzCallback } from "@antzsoft/wso2-auth-web/vue";
const { isLoading, error } = useAntzCallback(
client,
() => router.push("/dashboard"),
(err) => router.push(`/?error=${err.message}`)
);Integration Guides
React / Vite SPA
1. Create the client singleton (src/auth.ts):
import { AntzAuthClient } from "@antzsoft/wso2-auth-web";
const client = new AntzAuthClient({
baseUrl: import.meta.env.VITE_WSO2_BASE_URL,
clientId: import.meta.env.VITE_WSO2_CLIENT_ID,
redirectUri: import.meta.env.VITE_WSO2_REDIRECT_URI,
tenant: import.meta.env.VITE_WSO2_TENANT || undefined,
scopes: ["openid", "profile", "email", "roles"],
});
export default client;2. .env:
VITE_WSO2_BASE_URL=https://auth.antzsystems.com
VITE_WSO2_TENANT=dev
VITE_WSO2_CLIENT_ID=your-client-id
VITE_WSO2_REDIRECT_URI=http://localhost:5173/callback3. Login page (src/pages/LoginPage.tsx):
import { useAntzAuth } from "@antzsoft/wso2-auth-web/react";
import client from "../auth";
export default function LoginPage() {
const { login } = useAntzAuth(client);
return <button onClick={() => login()}>Sign in</button>;
}4. Callback page (src/pages/CallbackPage.tsx):
import { useAntzCallback } from "@antzsoft/wso2-auth-web/react";
import { useNavigate } from "react-router-dom";
import client from "../auth";
export default function CallbackPage() {
const navigate = useNavigate();
const { isLoading, error } = useAntzCallback(
client,
() => navigate("/dashboard"),
(err) => navigate(`/?error=${err.message}`)
);
if (isLoading) return <p>Signing in…</p>;
if (error) return <p>Error: {error.message}</p>;
return null;
}5. Protected page (src/pages/DashboardPage.tsx):
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useAntzAuth } from "@antzsoft/wso2-auth-web/react";
import client from "../auth";
export default function DashboardPage() {
const navigate = useNavigate();
const { isAuthenticated, isLoading, user, logout } = useAntzAuth(client, {
onSessionExpired: () => navigate("/?error=Session+expired"),
});
useEffect(() => {
if (!isLoading && !isAuthenticated) navigate("/");
}, [isLoading, isAuthenticated, navigate]);
if (isLoading || !user) return null;
return (
<div>
<p>Welcome, {user.name}</p>
<button onClick={logout}>Sign out</button>
</div>
);
}6. vite.config.ts — add a dev proxy if WSO2 CORS is not configured:
export default {
server: {
proxy: {
"/api/auth": {
target: "https://auth.antzsystems.com",
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/auth/, ""),
},
},
},
};Vue 3 / Vite SPA
1. Client singleton (src/auth.ts):
import { AntzAuthClient } from "@antzsoft/wso2-auth-web";
export const client = new AntzAuthClient({
baseUrl: import.meta.env.VITE_WSO2_BASE_URL,
clientId: import.meta.env.VITE_WSO2_CLIENT_ID,
redirectUri: import.meta.env.VITE_WSO2_REDIRECT_URI,
tenant: import.meta.env.VITE_WSO2_TENANT || undefined,
scopes: ["openid", "profile", "email", "roles"],
});2. Login page (src/pages/LoginPage.vue):
<script setup lang="ts">
import { useAntzAuth } from "@antzsoft/wso2-auth-web/vue";
import { client } from "../auth";
const { login } = useAntzAuth(client);
</script>
<template>
<button @click="login()">Sign in</button>
</template>3. Callback page (src/pages/CallbackPage.vue):
<script setup lang="ts">
import { useAntzCallback } from "@antzsoft/wso2-auth-web/vue";
import { useRouter } from "vue-router";
import { client } from "../auth";
const router = useRouter();
const { isLoading, error } = useAntzCallback(
client,
() => router.push("/dashboard"),
(err) => router.push(`/?error=${err.message}`)
);
</script>
<template>
<p v-if="isLoading">Signing in…</p>
<p v-else-if="error">Error: {{ error.message }}</p>
</template>4. Protected page (src/pages/DashboardPage.vue):
<script setup lang="ts">
import { watchEffect } from "vue";
import { useRouter } from "vue-router";
import { useAntzAuth } from "@antzsoft/wso2-auth-web/vue";
import { client } from "../auth";
const router = useRouter();
const { isAuthenticated, isLoading, user, logout } = useAntzAuth(client, {
onSessionExpired: () => router.replace("/?error=Session+expired"),
});
watchEffect(() => {
if (!isLoading.value && !isAuthenticated.value) router.replace("/");
});
</script>
<template>
<div v-if="user">
<p>Welcome, {{ user.name }}</p>
<button @click="logout()">Sign out</button>
</div>
</template>Next.js with Proxy Routes (recommended)
Use this when WSO2 CORS is not configured for your app's origin. The proxy routes run server-side (Node.js → WSO2), bypassing CORS entirely.
1. Client singleton (src/lib/auth.ts):
import { AntzAuthClient } from "@antzsoft/wso2-auth-web";
const client = new AntzAuthClient({
baseUrl: process.env.NEXT_PUBLIC_WSO2_BASE_URL!,
clientId: process.env.NEXT_PUBLIC_WSO2_CLIENT_ID!,
redirectUri: process.env.NEXT_PUBLIC_WSO2_REDIRECT_URI!,
tenant: process.env.NEXT_PUBLIC_WSO2_TENANT || undefined,
scopes: ["openid", "profile", "email", "roles"],
proxyUrl: "/api/auth/change-password",
});
export default client;2. .env.local:
NEXT_PUBLIC_WSO2_BASE_URL=https://auth.antzsystems.com
NEXT_PUBLIC_WSO2_TENANT=dev
NEXT_PUBLIC_WSO2_CLIENT_ID=your-client-id
NEXT_PUBLIC_WSO2_REDIRECT_URI=http://localhost:3000/callback3. WSO2 callback relay (src/app/api/auth/callback/route.ts):
import { NextRequest, NextResponse } from "next/server";
export async function GET(req: NextRequest) {
const { searchParams, origin } = new URL(req.url);
const error = searchParams.get("error");
if (error) {
return NextResponse.redirect(`${origin}/?error=${encodeURIComponent(error)}`);
}
return NextResponse.redirect(`${origin}/callback?${searchParams.toString()}`);
}4. Proxy route — send OTP (src/app/api/auth/change-password/send-otp/route.ts):
import { NextRequest, NextResponse } from "next/server";
const WSO2_BASE = process.env.NEXT_PUBLIC_WSO2_BASE_URL!;
const TENANT = process.env.NEXT_PUBLIC_WSO2_TENANT ?? "";
function sendOtpUrl() {
const base = TENANT ? `${WSO2_BASE}/t/${TENANT}` : WSO2_BASE;
return `${base}/api/users/v1/me/change-password/send-otp`;
}
function extractAlbCookies(cookieHeader: string): string {
return cookieHeader.split(";").map(c => c.trim())
.filter(c => c.startsWith("AWSALB=") || c.startsWith("AWSALBCORS="))
.join("; ");
}
export async function POST(req: NextRequest) {
try {
const authHeader = req.headers.get("authorization") ?? "";
if (!authHeader.startsWith("Bearer ")) {
return NextResponse.json({ error: "Missing Bearer token" }, { status: 401 });
}
const albCookie = extractAlbCookies(req.headers.get("cookie") ?? "");
const doFetch = (withCookie: boolean) => fetch(sendOtpUrl(), {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: authHeader,
...(withCookie && albCookie ? { Cookie: albCookie } : {}),
},
});
let wso2Res = await doFetch(true);
// Retry without stale ALB sticky cookie on 401
if (wso2Res.status === 401 && albCookie) {
wso2Res = await doFetch(false);
}
if (wso2Res.ok) {
const body = await wso2Res.json().catch(() => ({}));
const res = NextResponse.json({ otpRequired: true, message: body.message ?? undefined });
// Propagate new ALB sticky cookie to browser
for (const [k, v] of wso2Res.headers.entries()) {
if (k.toLowerCase() === "set-cookie") res.headers.append("set-cookie", v);
}
return res;
}
const rawBody = await wso2Res.text();
const parsed = JSON.parse(rawBody).catch?.(() => ({})) ?? {};
if (wso2Res.status === 400 && parsed.code === "OTP_NOT_ENABLED") {
return NextResponse.json({ otpRequired: false });
}
return NextResponse.json(parsed, { status: wso2Res.status });
} catch (err) {
console.error("[send-otp]", err);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}5. Proxy route — change password (src/app/api/auth/change-password/route.ts):
import { NextRequest, NextResponse } from "next/server";
const WSO2_BASE = process.env.NEXT_PUBLIC_WSO2_BASE_URL!;
const TENANT = process.env.NEXT_PUBLIC_WSO2_TENANT ?? "";
function changePasswordUrl() {
const base = TENANT ? `${WSO2_BASE}/t/${TENANT}` : WSO2_BASE;
return `${base}/api/users/v1/me/change-password`;
}
function extractAlbCookies(cookieHeader: string): string {
return cookieHeader.split(";").map(c => c.trim())
.filter(c => c.startsWith("AWSALB=") || c.startsWith("AWSALBCORS="))
.join("; ");
}
export async function POST(req: NextRequest) {
try {
const authHeader = req.headers.get("authorization") ?? "";
if (!authHeader.startsWith("Bearer ")) {
return NextResponse.json({ error: "Missing Bearer token" }, { status: 401 });
}
let body: { currentPassword?: string; newPassword?: string; otp?: string };
try { body = await req.json(); }
catch { return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); }
const { currentPassword, newPassword, otp } = body;
if (!currentPassword || !newPassword) {
return NextResponse.json({ error: "currentPassword and newPassword are required" }, { status: 400 });
}
const wso2Payload: Record<string, string> = { currentPassword, newPassword };
if (otp) wso2Payload.otp = otp;
const albCookie = extractAlbCookies(req.headers.get("cookie") ?? "");
const wso2Res = await fetch(changePasswordUrl(), {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: authHeader,
...(albCookie ? { Cookie: albCookie } : {}),
},
body: JSON.stringify(wso2Payload),
});
if (wso2Res.ok) return NextResponse.json({ message: "Password changed successfully" });
const rawBody = await wso2Res.text();
let parsed: { code?: string; message?: string } = {};
try { parsed = JSON.parse(rawBody); } catch { /* not JSON */ }
return NextResponse.json(parsed, { status: wso2Res.status });
} catch (err) {
console.error("[change-password]", err);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}6. next.config.mjs:
/** @type {import('next').NextConfig} */
const nextConfig = {
transpilePackages: ["@antzsoft/wso2-auth-web"],
};
export default nextConfig;Proxy URL routing summary:
| Package method | Proxy route called |
|----------------|-------------------|
| client.sendOtp() | POST /api/auth/change-password/send-otp |
| client.changePassword() | POST /api/auth/change-password |
Next.js Client-Only
Use this when WSO2 CORS is configured to allow your app's origin. No proxy routes needed — all calls go directly from the browser to WSO2.
1. Client singleton (src/lib/auth.ts):
import { AntzAuthClient } from "@antzsoft/wso2-auth-web";
const client = new AntzAuthClient({
baseUrl: process.env.NEXT_PUBLIC_WSO2_BASE_URL!,
clientId: process.env.NEXT_PUBLIC_WSO2_CLIENT_ID!,
redirectUri: process.env.NEXT_PUBLIC_WSO2_REDIRECT_URI!,
tenant: process.env.NEXT_PUBLIC_WSO2_TENANT || undefined,
scopes: ["openid", "profile", "email", "roles"],
// No proxyUrl — direct browser → WSO2 calls
});
export default client;2. Callback page (src/app/callback/page.tsx):
Since there is no server-side /api/auth/callback route, register the client page URL directly as the redirectUri in WSO2 Console (http://localhost:3000/callback). The client.handleCallback() call reads the code from window.location.href directly.
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import client from "@/lib/auth";
export default function CallbackPage() {
const router = useRouter();
useEffect(() => {
client.handleCallback()
.then(() => router.replace("/dashboard"))
.catch((err) => router.replace(`/?error=${encodeURIComponent(err.message)}`));
}, []);
return <p>Signing in…</p>;
}3. All pages are "use client" — the package uses sessionStorage and window which are browser-only APIs.
Vanilla JS / TypeScript
import { AntzAuthClient } from "@antzsoft/wso2-auth-web";
const client = new AntzAuthClient({
baseUrl: "https://auth.antzsystems.com",
clientId: "your-client-id",
redirectUri: "http://localhost:5173/callback.html",
tenant: "dev",
scopes: ["openid", "profile", "email"],
});
// Login
document.getElementById("login-btn")?.addEventListener("click", () => {
client.login();
});
// Callback page — call once on callback.html
if (window.location.pathname === "/callback.html") {
client.handleCallback()
.then(() => window.location.replace("/dashboard.html"))
.catch((err) => window.location.replace(`/?error=${err.message}`));
}
// Dashboard — check auth
if (!client.isAuthenticated()) {
window.location.replace("/");
}
const user = client.getUser();
console.log("Logged in as:", user?.name);
// Auto-logout when refresh token expires — same logout() as manual button
setInterval(async () => {
if (!client.isAuthenticated()) return;
const token = await client.getAccessToken();
if (!token) client.logout();
}, 60_000);
// Also check on tab focus (catches expiry while tab was hidden)
document.addEventListener("visibilitychange", async () => {
if (document.visibilityState !== "visible") return;
if (!client.isAuthenticated()) return;
const token = await client.getAccessToken();
if (!token) client.logout();
});
// Logout
document.getElementById("logout-btn")?.addEventListener("click", () => {
client.logout();
});CORS and Proxy Explained
WSO2's internal REST APIs (/api/users/v1/me/...) block direct cross-origin browser requests. This affects sendOtp() and changePassword().
Without proxy (browser → WSO2 directly):
Browser fetches https://auth.antzsystems.com/api/users/v1/me/change-password
→ Browser sends CORS preflight (OPTIONS)
→ WSO2 returns no CORS headers for your origin
→ Browser blocks the requestOption A — Configure WSO2 CORS (React, Vue, client-only Next.js)
Add to WSO2 deployment.toml:
[cors]
allow_generic_http_requests = true
allow_any_origin = false
allowed_origins = ["http://localhost:5173", "https://yourapp.com"]
support_any_header = true
supports_credentials = trueThen omit proxyUrl from the client config.
Option B — Use proxy routes (Next.js with server)
Set proxyUrl in the client config. Add the two API routes shown in the Next.js with Proxy Routes section. The browser calls your own origin → your server forwards to WSO2 server-side — no CORS.
With proxy (browser → Next.js server → WSO2):
Browser fetches http://localhost:3000/api/auth/change-password/send-otp (same origin, no CORS)
→ Next.js API route fetches https://auth.antzsystems.com/... (server→server, no CORS)Token Refresh & Session Expiry
Token refresh is fully automatic in the React and Vue adapters — you do not need to call anything yourself.
How it works
Three mechanisms fire automatically (all configurable via refreshBufferSeconds):
| Mechanism | When it fires |
|---|---|
| Session restore | On mount — restores tokens from storage, refreshes if expiring soon |
| Proactive timer | refreshBufferSeconds (default 60s) before access token expires — silent background refresh |
| Tab visibility | When user switches back to the tab — catches tokens that expired while tab was hidden |
Configuring the refresh buffer
By default the proactive timer fires 60 seconds before the access token expires. Override via refreshBufferSeconds:
const client = new AntzAuthClient({
baseUrl: "https://auth.antzsystems.com",
clientId: "your-client-id",
redirectUri: "https://yourapp.com/callback",
refreshBufferSeconds: 30, // refresh 30s before expiry instead of 60s
});Important: refreshBufferSeconds must be less than your WSO2 refresh token expiry time.
When the refresh token expires
If the refresh token is expired or revoked (e.g. server-side session invalidated), the package:
- Clears all tokens from storage
- Sets
status = 'unauthenticated'(React) /status.value = 'unauthenticated'(Vue)
The recommended pattern is to call logout() when status becomes 'unauthenticated'. This ensures the same cleanup path (token revocation + SSO session termination) runs for both manual logout and session expiry — no separate handling needed.
React — watch status and call logout() (recommended):
const { status, logout } = useAntzAuth(client);
// In a dashboard or layout component:
useEffect(() => {
if (status === "unauthenticated") logout();
}, [status, logout]);
// In a route guard / layout — show loading while session restores:
if (status === "idle" || status === "loading") return <div>Loading…</div>;
if (status === "unauthenticated") return null; // logout() handles navigation
return <>{children}</>;React — onSessionExpired callback (alternative):
useAntzAuth(client, {
onSessionExpired: () => router.replace("/login"),
});Vue — watch status and call logout() (recommended):
const { status, logout } = useAntzAuth(client);
watch(status, (s) => {
if (s === "unauthenticated") logout();
});Vue — onSessionExpired callback (alternative):
useAntzAuth(client, {
onSessionExpired: () => router.replace("/login"),
});Vanilla JS (manual polling):
// Poll every 30s
setInterval(async () => {
if (!client.isAuthenticated()) return;
const token = await client.getAccessToken();
if (!token) client.logout(); // same as manual logout — revokes + terminates SSO session
}, 30_000);
// Also check on tab focus (catches expiry while tab was hidden)
document.addEventListener("visibilitychange", async () => {
if (document.visibilityState !== "visible") return;
if (!client.isAuthenticated()) return;
const token = await client.getAccessToken();
if (!token) client.logout();
});Change Password Flow
1. User fills in currentPassword + newPassword
│
▼
2. client.sendOtp()
│
├─ { otpRequired: false } ──────────────────────────────────────────┐
│ OTP disabled for this app — skip OTP step │
│ │
└─ { otpRequired: true, message? } ──────────────────────────────┐ │
OTP sent to user's mobile/email │ │
Show OTP input field │ │
│ │
3. User enters 6-digit OTP │ │
│ │ │
▼ │ │
4. client.changePassword(currentPassword, newPassword, otp?) ◄─────────────┘──┘
│
├─ success → password changed
│
└─ throws:
AntzInvalidCredentialsError — wrong current password
AntzInvalidOtpError — wrong OTP
AntzOtpExpiredError — OTP TTL elapsed (5 min)
AntzOtpRequiredError — OTP enabled but not provided
AntzOtpMaxAttemptsError — 3 wrong codes, request new OTP
AntzPasswordPolicyError — policy violation
AntzSessionExpiredError — token expired, re-login