@loka-sms/sso
v1.2.0
Published
SSO utilities, hooks, and components for Loka SMS modules (OAuth2 PKCE, cross-app logout, infinite-loop protection)
Readme
@loka-sms/sso
SSO utilities, hooks, and components for Loka SMS modules.
Ticket exchange + localStorage auth for cross-app navigation. OAuth2 PKCE for third-party/external apps.
Install
npm install @loka-sms/ssoQuick Start — Cross-App Ticket Transfer
Gateway issues a short one-time ticket. The target app receives /auth/transfer?ticket=..., exchanges it for accessToken + refreshToken, stores both in localStorage, then redirects.
1. Navigate from source app
import { navigateToApp } from '@loka-sms/sso';
await navigateToApp({
targetUrl: import.meta.env.VITE_ADMIN_URL,
apiBase: import.meta.env.VITE_API_URL || '/api',
clientId: 'core',
targetBlank: true,
});navigateToApp() matches the current Gateway contract:
| Step | Request |
|------|---------|
| Issue ticket | POST /api/sso/issue-ticket with body { client_id, redirect } and Authorization: Bearer <accessToken> |
| Open app | <target-origin>/auth/transfer?ticket=<uuid>&next=<path> |
The helper opens about:blank synchronously first when targetBlank=true, so browser popup blockers do not block the cross-app navigation.
Use the lower-level helper if you only need a ticket:
import { issueTicket } from '@loka-sms/sso';
const { ticket, expiresIn } = await issueTicket({
apiBase: '/api',
clientId: 'core',
redirect: 'https://admin.example.com',
});2. Handle transfer in target app
import { OAuthTransfer } from '@loka-sms/sso';
<Route
path="/auth/transfer"
element={<OAuthTransfer apiBase="/api" clientId="administrasi" />}
/>Supported URL formats:
| URL | Behavior |
|-----|----------|
| /auth/transfer?ticket=<uuid>&next=/ | Exchanges ticket at POST /sso/exchange, stores tokens, redirects to next |
| /auth/transfer?token=<jwt>&refreshToken=<jwt> | Legacy fallback, stores URL tokens directly |
The component is guarded with useRef, so React dev-mode double effects do not consume single-use tickets twice.
Quick Start — Same-Domain App
App runs on same domain as Gateway (e.g. 10.7.1.82). Cookie sms_ac_token can still be used, but localStorage is now the primary app-side cache.
1. Setup API client
// src/api/client.ts
import axios from 'axios';
import { createAuthInterceptor } from '@loka-sms/sso';
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || '/api',
withCredentials: true,
});
api.interceptors.request.use(createAuthInterceptor());
export default api;Headers automatically attached:
| Header | Source |
|--------|--------|
| Authorization: Bearer <token> | Cookie sms_ac_token → localStorage fallback |
| X-School-ID | localStorage school_sms_id |
| X-User-ID | localStorage user_id |
| X-User-Role | localStorage user_role |
| X-Device-ID | localStorage device_sms_id |
| X-Request-ID | crypto.randomUUID() |
2. Setup Auth Store
// src/stores/authStore.ts
function getCookie(name: string): string {
const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'));
return match ? decodeURIComponent(match[2]) : '';
}
function rehydrateUser() {
const cached = localStorage.getItem('my_user');
if (cached) return JSON.parse(cached);
const token = getCookie('sms_ac_token');
if (!token) return null;
const payload = JSON.parse(atob(token.split('.')[1]));
const user = {
sub: payload.sub, email: payload.email, fullName: payload.fullName,
role: payload.role, schoolId: payload.schoolId || '',
};
localStorage.setItem('my_user', JSON.stringify(user));
return user;
}3. Add routes
import { OAuthCallback } from '@loka-sms/sso';
import SignIn from './pages/SignIn';
<Route path="/signin" element={<SignIn />} />
<Route path="/auth/callback" element={<OAuthCallback clientId="my-module" />} />4. Listen for cross-app logout
import { useCrossAppLogout } from '@loka-sms/sso';
function App() {
useCrossAppLogout(); // listens on BroadcastChannel 'loka-sso-logout'
// ...
}Quick Start — Third-Party App (OAuth2 PKCE)
App on different domain from Gateway. Full PKCE flow required.
1. Redirect user to authorize
import { generateCodeVerifier, generateCodeChallenge, generateState } from '@loka-sms/sso';
const verifier = generateCodeVerifier();
const state = generateState();
sessionStorage.setItem(`oauth_verifier_${state}`, verifier);
const challenge = await generateCodeChallenge(verifier);
const params = new URLSearchParams({
client_id: 'my-app',
redirect_uri: 'https://my-app.com/auth/callback',
response_type: 'code',
code_challenge: challenge,
code_challenge_method: 'S256',
state,
});
window.location.href = `https://gateway.loka.id/api/oauth/authorize?${params}`;2. Handle callback
import { OAuthCallback } from '@loka-sms/sso';
<Route path="/auth/callback" element={<OAuthCallback clientId="my-app" />} />Or use the hook directly:
import { useOAuthCallback } from '@loka-sms/sso';
function AuthCallback() {
const { error, loading, phase } = useOAuthCallback({
clientId: 'my-app',
apiBase: 'https://gateway.loka.id/api',
redirectPath: '/dashboard',
});
// ...
}API Reference
Interceptor
| Export | Description |
|--------|-------------|
| createAuthInterceptor() | Axios request interceptor. Reads token cookie-first, attaches Authorization + context headers. |
Hooks
| Export | Description |
|--------|-------------|
| useCrossAppLogout() | Listens on BroadcastChannel loka-sso-logout. Clears auth state and redirects to /signin. Built-in 2-second dedup to prevent re-broadcast loops. |
| useOAuthCallback(input) | Handles OAuth2 PKCE code exchange. Returns { error, loading, phase }. Awaits cookie sync before redirect to prevent landing-page race. |
| useIssueTicket(apiBase) | Issues a one-time ticket using current localStorage/cookie access token. |
| useNavigateToApp(apiBase) | React state wrapper for navigateToApp(). |
Ticket Utilities
| Export | Description |
|--------|-------------|
| issueTicket(options) | Calls POST /sso/issue-ticket with { client_id, redirect }. |
| exchangeTicket(options) | Calls POST /sso/exchange with { ticket, clientId }. |
| navigateToApp(options) | Issues ticket, opens target /auth/transfer?ticket=...&next=.... |
| buildTransferUrl(targetUrl, ticket, next?) | Builds the target app transfer URL without network calls. |
useOAuthCallback input:
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| clientId | string | required | OAuth2 client ID |
| apiBase | string | '/api' | Gateway API base URL |
| redirectPath | string | '/' | Redirect path after success |
Components
| Export | Description |
|--------|-------------|
| <OAuthCallback clientId /> | Drop-in OAuth2 PKCE callback page. Wraps useOAuthCallback. |
| <OAuthTransfer /> | Handles ticket exchange (?ticket=) and legacy token-in-URL fallback. |
PKCE Utilities
| Export | Description |
|--------|-------------|
| generateCodeVerifier() | Generate cryptographically random code verifier |
| generateCodeChallenge(verifier) | SHA-256 hash → base64url encode |
| generateState() | Generate random state string for CSRF protection |
| sha256(input) | Pure JS SHA-256 implementation |
Constants
| Export | Description |
|--------|-------------|
| SSO_STORAGE_KEYS | Standard localStorage key names |
| SSO_CHANNELS | BroadcastChannel names (loka-sso-logout, loka-sso-login, loka-school-change) |
| API_HEADERS | Standard API header names |
Architecture
Cross-app modules → short ticket in URL → `/sso/exchange` → localStorage tokens
Same-domain apps (10.7.1.82:*) → cookie/localStorage token → auto-login
Third-party apps (external domain) → OAuth2 PKCE → code exchange → tokenTicket URLs should carry only a short one-time ticket, never the full JWT. See docs/flow_baru.md for full architecture.
