@loka-sms/sso 1.1.0 → 1.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +76 -6
- package/dist/auth.d.ts +39 -0
- package/dist/auth.js +236 -0
- package/dist/components/OAuthTransfer.d.ts +2 -1
- package/dist/components/OAuthTransfer.js +71 -46
- package/dist/hooks/useIssueTicket.d.ts +8 -0
- package/dist/hooks/useIssueTicket.js +28 -0
- package/dist/hooks/useNavigateToApp.d.ts +7 -0
- package/dist/hooks/useNavigateToApp.js +25 -0
- package/dist/hooks/useOAuthCallback.js +1 -10
- package/dist/index.d.ts +10 -0
- package/dist/index.js +19 -1
- package/dist/navigation.d.ts +11 -0
- package/dist/navigation.js +56 -0
- package/dist/ticket.d.ts +29 -0
- package/dist/ticket.js +120 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
SSO utilities, hooks, and components for Loka SMS modules.
|
|
4
4
|
|
|
5
|
-
**
|
|
5
|
+
**Ticket exchange + localStorage auth** for cross-app navigation. **OAuth2 PKCE** for third-party/external apps.
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
@@ -10,9 +10,67 @@ SSO utilities, hooks, and components for Loka SMS modules.
|
|
|
10
10
|
npm install @loka-sms/sso
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
## Quick Start —
|
|
13
|
+
## Quick Start — Cross-App Ticket Transfer
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
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.
|
|
16
|
+
|
|
17
|
+
### 1. Navigate from source app
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { navigateToApp } from '@loka-sms/sso';
|
|
21
|
+
|
|
22
|
+
await navigateToApp({
|
|
23
|
+
targetUrl: import.meta.env.VITE_ADMIN_URL,
|
|
24
|
+
apiBase: import.meta.env.VITE_API_URL || '/api',
|
|
25
|
+
clientId: 'core',
|
|
26
|
+
targetBlank: true,
|
|
27
|
+
});
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`navigateToApp()` matches the current Gateway contract:
|
|
31
|
+
|
|
32
|
+
| Step | Request |
|
|
33
|
+
|------|---------|
|
|
34
|
+
| Issue ticket | `POST /api/sso/issue-ticket` with body `{ client_id, redirect }` and `Authorization: Bearer <accessToken>` |
|
|
35
|
+
| Open app | `<target-origin>/auth/transfer?ticket=<uuid>&next=<path>` |
|
|
36
|
+
|
|
37
|
+
The helper opens `about:blank` synchronously first when `targetBlank=true`, so browser popup blockers do not block the cross-app navigation.
|
|
38
|
+
|
|
39
|
+
Use the lower-level helper if you only need a ticket:
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
import { issueTicket } from '@loka-sms/sso';
|
|
43
|
+
|
|
44
|
+
const { ticket, expiresIn } = await issueTicket({
|
|
45
|
+
apiBase: '/api',
|
|
46
|
+
clientId: 'core',
|
|
47
|
+
redirect: 'https://admin.example.com',
|
|
48
|
+
});
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### 2. Handle transfer in target app
|
|
52
|
+
|
|
53
|
+
```tsx
|
|
54
|
+
import { OAuthTransfer } from '@loka-sms/sso';
|
|
55
|
+
|
|
56
|
+
<Route
|
|
57
|
+
path="/auth/transfer"
|
|
58
|
+
element={<OAuthTransfer apiBase="/api" clientId="administrasi" />}
|
|
59
|
+
/>
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Supported URL formats:
|
|
63
|
+
|
|
64
|
+
| URL | Behavior |
|
|
65
|
+
|-----|----------|
|
|
66
|
+
| `/auth/transfer?ticket=<uuid>&next=/` | Exchanges ticket at `POST /sso/exchange`, stores tokens, redirects to `next` |
|
|
67
|
+
| `/auth/transfer?token=<jwt>&refreshToken=<jwt>` | Legacy fallback, stores URL tokens directly |
|
|
68
|
+
|
|
69
|
+
The component is guarded with `useRef`, so React dev-mode double effects do not consume single-use tickets twice.
|
|
70
|
+
|
|
71
|
+
## Quick Start — Same-Domain App
|
|
72
|
+
|
|
73
|
+
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.
|
|
16
74
|
|
|
17
75
|
### 1. Setup API client
|
|
18
76
|
|
|
@@ -150,6 +208,17 @@ function AuthCallback() {
|
|
|
150
208
|
|--------|-------------|
|
|
151
209
|
| `useCrossAppLogout()` | Listens on BroadcastChannel `loka-sso-logout`. Clears auth state and redirects to `/signin`. |
|
|
152
210
|
| `useOAuthCallback(input)` | Handles OAuth2 PKCE code exchange. Returns `{ error, loading, phase }`. |
|
|
211
|
+
| `useIssueTicket(apiBase)` | Issues a one-time ticket using current localStorage/cookie access token. |
|
|
212
|
+
| `useNavigateToApp(apiBase)` | React state wrapper for `navigateToApp()`. |
|
|
213
|
+
|
|
214
|
+
### Ticket Utilities
|
|
215
|
+
|
|
216
|
+
| Export | Description |
|
|
217
|
+
|--------|-------------|
|
|
218
|
+
| `issueTicket(options)` | Calls `POST /sso/issue-ticket` with `{ client_id, redirect }`. |
|
|
219
|
+
| `exchangeTicket(options)` | Calls `POST /sso/exchange` with `{ ticket, clientId }`. |
|
|
220
|
+
| `navigateToApp(options)` | Issues ticket, opens target `/auth/transfer?ticket=...&next=...`. |
|
|
221
|
+
| `buildTransferUrl(targetUrl, ticket, next?)` | Builds the target app transfer URL without network calls. |
|
|
153
222
|
|
|
154
223
|
**`useOAuthCallback` input:**
|
|
155
224
|
|
|
@@ -164,7 +233,7 @@ function AuthCallback() {
|
|
|
164
233
|
| Export | Description |
|
|
165
234
|
|--------|-------------|
|
|
166
235
|
| `<OAuthCallback clientId />` | Drop-in OAuth2 PKCE callback page. Wraps `useOAuthCallback`. |
|
|
167
|
-
| `<OAuthTransfer />` |
|
|
236
|
+
| `<OAuthTransfer />` | Handles ticket exchange (`?ticket=`) and legacy token-in-URL fallback. |
|
|
168
237
|
|
|
169
238
|
### PKCE Utilities
|
|
170
239
|
|
|
@@ -186,8 +255,9 @@ function AuthCallback() {
|
|
|
186
255
|
## Architecture
|
|
187
256
|
|
|
188
257
|
```
|
|
189
|
-
|
|
258
|
+
Cross-app modules → short ticket in URL → `/sso/exchange` → localStorage tokens
|
|
259
|
+
Same-domain apps (10.7.1.82:*) → cookie/localStorage token → auto-login
|
|
190
260
|
Third-party apps (external domain) → OAuth2 PKCE → code exchange → token
|
|
191
261
|
```
|
|
192
262
|
|
|
193
|
-
|
|
263
|
+
Ticket URLs should carry only a short one-time ticket, never the full JWT. See [docs/flow_baru.md](../../docs/flow_baru.md) for full architecture.
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type IssueTicketResponse } from './ticket';
|
|
2
|
+
export interface AuthUser {
|
|
3
|
+
id?: string;
|
|
4
|
+
sub?: string;
|
|
5
|
+
name?: string;
|
|
6
|
+
fullName?: string;
|
|
7
|
+
email?: string;
|
|
8
|
+
role?: string;
|
|
9
|
+
schoolId?: string;
|
|
10
|
+
[key: string]: any;
|
|
11
|
+
}
|
|
12
|
+
interface AuthContextValue {
|
|
13
|
+
user: AuthUser | null;
|
|
14
|
+
token: string | null;
|
|
15
|
+
refreshToken: string | null;
|
|
16
|
+
isAuthenticated: boolean;
|
|
17
|
+
isLoading: boolean;
|
|
18
|
+
login: (email: string, password: string) => Promise<void>;
|
|
19
|
+
logout: () => Promise<void>;
|
|
20
|
+
refreshAuth: () => void;
|
|
21
|
+
issueTicket: (redirect?: string) => Promise<IssueTicketResponse>;
|
|
22
|
+
navigateToApp: (targetUrl: string, targetBlank?: boolean) => Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
interface AuthProviderProps {
|
|
25
|
+
apiUrl?: string;
|
|
26
|
+
gatewayUrl?: string;
|
|
27
|
+
coreUrl?: string;
|
|
28
|
+
clientId?: string;
|
|
29
|
+
children: React.ReactNode;
|
|
30
|
+
}
|
|
31
|
+
export interface AuthAppProps extends Omit<AuthProviderProps, 'children'> {
|
|
32
|
+
children: React.ReactNode;
|
|
33
|
+
}
|
|
34
|
+
export declare function useAuth(): AuthContextValue;
|
|
35
|
+
export declare function ProtectedRoute({ children }: {
|
|
36
|
+
children?: React.ReactNode;
|
|
37
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
38
|
+
export declare function AuthApp({ apiUrl, gatewayUrl, coreUrl, clientId, children, }: AuthAppProps): import("react/jsx-runtime").JSX.Element;
|
|
39
|
+
export {};
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.useAuth = useAuth;
|
|
4
|
+
exports.ProtectedRoute = ProtectedRoute;
|
|
5
|
+
exports.AuthApp = AuthApp;
|
|
6
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
7
|
+
const react_1 = require("react");
|
|
8
|
+
const react_router_1 = require("react-router");
|
|
9
|
+
const OAuthCallback_1 = require("./components/OAuthCallback");
|
|
10
|
+
const OAuthTransfer_1 = require("./components/OAuthTransfer");
|
|
11
|
+
const constants_1 = require("./constants");
|
|
12
|
+
const navigation_1 = require("./navigation");
|
|
13
|
+
const ticket_1 = require("./ticket");
|
|
14
|
+
const AuthContext = (0, react_1.createContext)(null);
|
|
15
|
+
function getCookie(name) {
|
|
16
|
+
const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'));
|
|
17
|
+
return match ? decodeURIComponent(match[2]) : '';
|
|
18
|
+
}
|
|
19
|
+
function clearCookie(name) {
|
|
20
|
+
document.cookie = `${name}=; path=/; Max-Age=0; SameSite=Lax`;
|
|
21
|
+
}
|
|
22
|
+
function decodeJwt(token) {
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(atob(token.split('.')[1]));
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function isExpired(token) {
|
|
31
|
+
const payload = decodeJwt(token);
|
|
32
|
+
if (!payload?.exp)
|
|
33
|
+
return false;
|
|
34
|
+
return payload.exp * 1000 <= Date.now();
|
|
35
|
+
}
|
|
36
|
+
function userFromToken(token) {
|
|
37
|
+
const payload = decodeJwt(token);
|
|
38
|
+
if (!payload)
|
|
39
|
+
return null;
|
|
40
|
+
return {
|
|
41
|
+
id: payload.sub,
|
|
42
|
+
sub: payload.sub,
|
|
43
|
+
name: payload.fullName || payload.name || payload.email,
|
|
44
|
+
fullName: payload.fullName || payload.name,
|
|
45
|
+
email: payload.email,
|
|
46
|
+
role: payload.role,
|
|
47
|
+
schoolId: payload.schoolId,
|
|
48
|
+
accessibleSchools: payload.accessibleSchools || [],
|
|
49
|
+
features: payload.features || [],
|
|
50
|
+
capabilities: payload.capabilities || [],
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function persistAuth(token, refreshToken, user) {
|
|
54
|
+
localStorage.setItem(constants_1.SSO_STORAGE_KEYS.ACCESS_TOKEN, token);
|
|
55
|
+
if (refreshToken)
|
|
56
|
+
localStorage.setItem(constants_1.SSO_STORAGE_KEYS.REFRESH_TOKEN, refreshToken);
|
|
57
|
+
const nextUser = user || userFromToken(token);
|
|
58
|
+
if (nextUser) {
|
|
59
|
+
localStorage.setItem(constants_1.SSO_STORAGE_KEYS.USER_PROFILE, JSON.stringify(nextUser));
|
|
60
|
+
localStorage.setItem(constants_1.SSO_STORAGE_KEYS.USER_ID, nextUser.sub || nextUser.id || '');
|
|
61
|
+
localStorage.setItem(constants_1.SSO_STORAGE_KEYS.USER_ROLE, (nextUser.role || '').toLowerCase());
|
|
62
|
+
if (nextUser.schoolId)
|
|
63
|
+
localStorage.setItem(constants_1.SSO_STORAGE_KEYS.SCHOOL_ID, nextUser.schoolId);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function clearAuthStorage() {
|
|
67
|
+
localStorage.removeItem(constants_1.SSO_STORAGE_KEYS.ACCESS_TOKEN);
|
|
68
|
+
localStorage.removeItem(constants_1.SSO_STORAGE_KEYS.REFRESH_TOKEN);
|
|
69
|
+
localStorage.removeItem(constants_1.SSO_STORAGE_KEYS.USER_PROFILE);
|
|
70
|
+
localStorage.removeItem(constants_1.SSO_STORAGE_KEYS.USER_ID);
|
|
71
|
+
localStorage.removeItem(constants_1.SSO_STORAGE_KEYS.USER_ROLE);
|
|
72
|
+
localStorage.removeItem(constants_1.SSO_STORAGE_KEYS.SCHOOL_ID);
|
|
73
|
+
clearCookie('sms_ac_token');
|
|
74
|
+
clearCookie('sms_refresh_token');
|
|
75
|
+
clearCookie('sms_user_profile');
|
|
76
|
+
clearCookie('sms_school_id');
|
|
77
|
+
}
|
|
78
|
+
function readStoredAuth() {
|
|
79
|
+
const token = localStorage.getItem(constants_1.SSO_STORAGE_KEYS.ACCESS_TOKEN) || getCookie(constants_1.SSO_STORAGE_KEYS.ACCESS_TOKEN);
|
|
80
|
+
const refreshToken = localStorage.getItem(constants_1.SSO_STORAGE_KEYS.REFRESH_TOKEN) || getCookie(constants_1.SSO_STORAGE_KEYS.REFRESH_TOKEN);
|
|
81
|
+
if (!token || isExpired(token))
|
|
82
|
+
return { token: null, refreshToken: null, user: null };
|
|
83
|
+
let user = null;
|
|
84
|
+
try {
|
|
85
|
+
const cached = localStorage.getItem(constants_1.SSO_STORAGE_KEYS.USER_PROFILE);
|
|
86
|
+
if (cached)
|
|
87
|
+
user = JSON.parse(cached);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
user = null;
|
|
91
|
+
}
|
|
92
|
+
user = user || userFromToken(token);
|
|
93
|
+
persistAuth(token, refreshToken || undefined, user);
|
|
94
|
+
return { token, refreshToken, user };
|
|
95
|
+
}
|
|
96
|
+
function broadcast(type, payload) {
|
|
97
|
+
try {
|
|
98
|
+
const channel = new BroadcastChannel(type === 'login' ? constants_1.SSO_CHANNELS.LOGIN : constants_1.SSO_CHANNELS.LOGOUT);
|
|
99
|
+
channel.postMessage(payload || { type });
|
|
100
|
+
channel.close();
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// BroadcastChannel is optional.
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function SignInPage({ apiUrl, coreUrl }) {
|
|
107
|
+
const { login } = useAuth();
|
|
108
|
+
const navigate = (0, react_router_1.useNavigate)();
|
|
109
|
+
const [email, setEmail] = (0, react_1.useState)('');
|
|
110
|
+
const [password, setPassword] = (0, react_1.useState)('');
|
|
111
|
+
const [error, setError] = (0, react_1.useState)('');
|
|
112
|
+
const [loading, setLoading] = (0, react_1.useState)(false);
|
|
113
|
+
const handleSubmit = async (event) => {
|
|
114
|
+
event.preventDefault();
|
|
115
|
+
setLoading(true);
|
|
116
|
+
setError('');
|
|
117
|
+
try {
|
|
118
|
+
await login(email, password);
|
|
119
|
+
navigate('/', { replace: true });
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
setError(err?.message || 'Login gagal. Periksa email dan password.');
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
setLoading(false);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
const handleCoreLogin = () => {
|
|
129
|
+
window.location.href = `${coreUrl || '/'}${coreUrl?.includes('?') ? '&' : '?'}redirect=${encodeURIComponent(window.location.href)}`;
|
|
130
|
+
};
|
|
131
|
+
return ((0, jsx_runtime_1.jsx)("div", { className: "flex min-h-screen items-center justify-center bg-gray-50 px-4", children: (0, jsx_runtime_1.jsxs)("form", { onSubmit: handleSubmit, className: "w-full max-w-md rounded-2xl bg-white p-8 shadow-lg", children: [(0, jsx_runtime_1.jsx)("h1", { className: "text-2xl font-semibold text-gray-900", children: "Masuk ke Loka SMS" }), (0, jsx_runtime_1.jsx)("p", { className: "mt-1 text-sm text-gray-500", children: "Gunakan akun sekolah Anda untuk melanjutkan." }), error && (0, jsx_runtime_1.jsx)("p", { className: "mt-4 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-600", children: error }), (0, jsx_runtime_1.jsxs)("label", { className: "mt-6 block text-sm font-medium text-gray-700", children: ["Email", (0, jsx_runtime_1.jsx)("input", { value: email, onChange: (event) => setEmail(event.target.value), type: "email", required: true, className: "mt-2 w-full rounded-lg border border-gray-300 px-3 py-2 outline-none focus:border-blue-500" })] }), (0, jsx_runtime_1.jsxs)("label", { className: "mt-4 block text-sm font-medium text-gray-700", children: ["Password", (0, jsx_runtime_1.jsx)("input", { value: password, onChange: (event) => setPassword(event.target.value), type: "password", required: true, className: "mt-2 w-full rounded-lg border border-gray-300 px-3 py-2 outline-none focus:border-blue-500" })] }), (0, jsx_runtime_1.jsx)("button", { type: "submit", disabled: loading, className: "mt-6 w-full rounded-lg bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:opacity-60", children: loading ? 'Memproses...' : 'Masuk' }), apiUrl && ((0, jsx_runtime_1.jsx)("button", { type: "button", onClick: handleCoreLogin, className: "mt-3 w-full rounded-lg border border-gray-300 px-4 py-2 font-medium text-gray-700 hover:bg-gray-50", children: "Masuk via Core" }))] }) }));
|
|
132
|
+
}
|
|
133
|
+
function AuthProvider({ apiUrl = '/api', clientId, children }) {
|
|
134
|
+
const [state, setState] = (0, react_1.useState)(() => {
|
|
135
|
+
const stored = readStoredAuth();
|
|
136
|
+
return {
|
|
137
|
+
user: stored.user,
|
|
138
|
+
token: stored.token,
|
|
139
|
+
refreshToken: stored.refreshToken,
|
|
140
|
+
isLoading: false,
|
|
141
|
+
};
|
|
142
|
+
});
|
|
143
|
+
const refreshAuth = () => {
|
|
144
|
+
const stored = readStoredAuth();
|
|
145
|
+
setState((prev) => ({ ...prev, ...stored, isLoading: false }));
|
|
146
|
+
};
|
|
147
|
+
(0, react_1.useEffect)(() => {
|
|
148
|
+
const onStorage = (event) => {
|
|
149
|
+
if (event.key === constants_1.SSO_STORAGE_KEYS.ACCESS_TOKEN || event.key === constants_1.SSO_STORAGE_KEYS.USER_PROFILE)
|
|
150
|
+
refreshAuth();
|
|
151
|
+
};
|
|
152
|
+
window.addEventListener('storage', onStorage);
|
|
153
|
+
let loginChannel = null;
|
|
154
|
+
let logoutChannel = null;
|
|
155
|
+
try {
|
|
156
|
+
loginChannel = new BroadcastChannel(constants_1.SSO_CHANNELS.LOGIN);
|
|
157
|
+
loginChannel.onmessage = refreshAuth;
|
|
158
|
+
logoutChannel = new BroadcastChannel(constants_1.SSO_CHANNELS.LOGOUT);
|
|
159
|
+
logoutChannel.onmessage = () => setState({ user: null, token: null, refreshToken: null, isLoading: false });
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
// BroadcastChannel is optional.
|
|
163
|
+
}
|
|
164
|
+
return () => {
|
|
165
|
+
window.removeEventListener('storage', onStorage);
|
|
166
|
+
loginChannel?.close();
|
|
167
|
+
logoutChannel?.close();
|
|
168
|
+
};
|
|
169
|
+
}, []);
|
|
170
|
+
const value = (0, react_1.useMemo)(() => ({
|
|
171
|
+
user: state.user,
|
|
172
|
+
token: state.token,
|
|
173
|
+
refreshToken: state.refreshToken,
|
|
174
|
+
isAuthenticated: Boolean(state.token && state.user),
|
|
175
|
+
isLoading: state.isLoading,
|
|
176
|
+
refreshAuth,
|
|
177
|
+
issueTicket: async (redirect) => (0, ticket_1.issueTicket)({
|
|
178
|
+
apiBase: apiUrl,
|
|
179
|
+
accessToken: state.token || undefined,
|
|
180
|
+
clientId,
|
|
181
|
+
redirect,
|
|
182
|
+
}),
|
|
183
|
+
navigateToApp: async (targetUrl, targetBlank = true) => (0, navigation_1.navigateToApp)({
|
|
184
|
+
targetUrl,
|
|
185
|
+
apiBase: apiUrl,
|
|
186
|
+
accessToken: state.token || undefined,
|
|
187
|
+
clientId,
|
|
188
|
+
targetBlank,
|
|
189
|
+
}),
|
|
190
|
+
login: async (email, password) => {
|
|
191
|
+
const response = await fetch(`${apiUrl}/auth/login`, {
|
|
192
|
+
method: 'POST',
|
|
193
|
+
headers: { 'Content-Type': 'application/json' },
|
|
194
|
+
credentials: 'include',
|
|
195
|
+
body: JSON.stringify({ email, password }),
|
|
196
|
+
});
|
|
197
|
+
const body = await response.json().catch(() => ({}));
|
|
198
|
+
if (!response.ok)
|
|
199
|
+
throw new Error(body.message || 'Login gagal.');
|
|
200
|
+
const data = body?.data ?? body;
|
|
201
|
+
const token = data.token || data.accessToken;
|
|
202
|
+
const refreshToken = data.refreshToken;
|
|
203
|
+
const user = data.user || userFromToken(token);
|
|
204
|
+
if (!token || !refreshToken)
|
|
205
|
+
throw new Error('Token login tidak lengkap.');
|
|
206
|
+
persistAuth(token, refreshToken, user);
|
|
207
|
+
setState({ user, token, refreshToken, isLoading: false });
|
|
208
|
+
broadcast('login', { token, user });
|
|
209
|
+
},
|
|
210
|
+
logout: async () => {
|
|
211
|
+
clearAuthStorage();
|
|
212
|
+
setState({ user: null, token: null, refreshToken: null, isLoading: false });
|
|
213
|
+
broadcast('logout');
|
|
214
|
+
fetch(`${apiUrl}/auth/logout`, { method: 'POST', credentials: 'include' }).catch(() => { });
|
|
215
|
+
},
|
|
216
|
+
}), [apiUrl, clientId, state]);
|
|
217
|
+
return (0, jsx_runtime_1.jsx)(AuthContext.Provider, { value: value, children: children });
|
|
218
|
+
}
|
|
219
|
+
function useAuth() {
|
|
220
|
+
const value = (0, react_1.useContext)(AuthContext);
|
|
221
|
+
if (!value)
|
|
222
|
+
throw new Error('useAuth must be used inside AuthApp');
|
|
223
|
+
return value;
|
|
224
|
+
}
|
|
225
|
+
function ProtectedRoute({ children }) {
|
|
226
|
+
const { isAuthenticated, isLoading } = useAuth();
|
|
227
|
+
if (isLoading)
|
|
228
|
+
return (0, jsx_runtime_1.jsx)("div", { className: "flex min-h-screen items-center justify-center", children: "Memuat..." });
|
|
229
|
+
if (!isAuthenticated)
|
|
230
|
+
return (0, jsx_runtime_1.jsx)(react_router_1.Navigate, { to: "/signin", replace: true });
|
|
231
|
+
return children ? (0, jsx_runtime_1.jsx)(jsx_runtime_1.Fragment, { children: children }) : (0, jsx_runtime_1.jsx)(react_router_1.Outlet, {});
|
|
232
|
+
}
|
|
233
|
+
function AuthApp({ apiUrl = '/api', gatewayUrl, coreUrl, clientId = 'loka-app', children, }) {
|
|
234
|
+
const transferApiBase = apiUrl || (gatewayUrl ? `${gatewayUrl.replace(/\/$/, '')}/api` : '/api');
|
|
235
|
+
return ((0, jsx_runtime_1.jsx)(AuthProvider, { apiUrl: apiUrl, gatewayUrl: gatewayUrl, coreUrl: coreUrl, clientId: clientId, children: (0, jsx_runtime_1.jsx)(react_router_1.BrowserRouter, { children: (0, jsx_runtime_1.jsxs)(react_router_1.Routes, { children: [(0, jsx_runtime_1.jsx)(react_router_1.Route, { path: "/signin", element: (0, jsx_runtime_1.jsx)(SignInPage, { apiUrl: apiUrl, coreUrl: coreUrl }) }), (0, jsx_runtime_1.jsx)(react_router_1.Route, { path: "/login", element: (0, jsx_runtime_1.jsx)(SignInPage, { apiUrl: apiUrl, coreUrl: coreUrl }) }), (0, jsx_runtime_1.jsx)(react_router_1.Route, { path: "/auth/transfer", element: (0, jsx_runtime_1.jsx)(OAuthTransfer_1.OAuthTransfer, { apiBase: transferApiBase, clientId: clientId }) }), (0, jsx_runtime_1.jsx)(react_router_1.Route, { path: "/auth/callback", element: (0, jsx_runtime_1.jsx)(OAuthCallback_1.OAuthCallback, { apiBase: apiUrl, clientId: clientId }) }), children] }) }) }));
|
|
236
|
+
}
|
|
@@ -2,5 +2,6 @@ export interface OAuthTransferProps {
|
|
|
2
2
|
apiBase?: string;
|
|
3
3
|
redirectPath?: string;
|
|
4
4
|
loginPath?: string;
|
|
5
|
+
clientId?: string;
|
|
5
6
|
}
|
|
6
|
-
export declare function OAuthTransfer({ apiBase, redirectPath, loginPath, }: OAuthTransferProps): import("react/jsx-runtime").JSX.Element;
|
|
7
|
+
export declare function OAuthTransfer({ apiBase, redirectPath, loginPath, clientId, }: OAuthTransferProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -2,59 +2,84 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.OAuthTransfer = OAuthTransfer;
|
|
4
4
|
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
5
|
-
// DEPRECATED: Cookie-based auth replaces token-in-URL transfer.
|
|
6
|
-
// Kept for backward compatibility with external SSO clients.
|
|
7
5
|
const react_1 = require("react");
|
|
8
|
-
|
|
6
|
+
const constants_1 = require("../constants");
|
|
7
|
+
const ticket_1 = require("../ticket");
|
|
8
|
+
const processedTickets = new Set();
|
|
9
|
+
function OAuthTransfer({ apiBase, redirectPath = '/', loginPath = '/signin', clientId, }) {
|
|
10
|
+
const [state, setState] = (0, react_1.useState)('loading');
|
|
9
11
|
const [error, setError] = (0, react_1.useState)('');
|
|
12
|
+
const hasProcessed = (0, react_1.useRef)(false);
|
|
10
13
|
(0, react_1.useEffect)(() => {
|
|
14
|
+
if (hasProcessed.current)
|
|
15
|
+
return;
|
|
16
|
+
hasProcessed.current = true;
|
|
11
17
|
const params = new URLSearchParams(window.location.search);
|
|
18
|
+
const ticket = params.get('ticket');
|
|
12
19
|
const token = params.get('token');
|
|
13
|
-
const refreshToken = params.get('refreshToken');
|
|
20
|
+
const refreshToken = params.get('refreshToken') || params.get('refresh_token');
|
|
21
|
+
const next = params.get('next') || redirectPath;
|
|
14
22
|
const base = apiBase || '/api';
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
23
|
+
async function fetchProfile(accessToken) {
|
|
24
|
+
try {
|
|
25
|
+
const profileRes = await fetch(`${base}/auth/me`, {
|
|
26
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
27
|
+
credentials: 'include',
|
|
28
|
+
});
|
|
29
|
+
const profileData = await profileRes.json();
|
|
30
|
+
const profile = profileData?.data ?? profileData;
|
|
31
|
+
if (profile) {
|
|
32
|
+
localStorage.setItem(constants_1.SSO_STORAGE_KEYS.USER_PROFILE, JSON.stringify(profile));
|
|
33
|
+
document.cookie = `sms_user_profile=${encodeURIComponent(JSON.stringify(profile))}; path=/; SameSite=Lax`;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// Profile sync is non-critical; token already exists in localStorage.
|
|
38
|
+
}
|
|
18
39
|
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
40
|
+
async function handleTicketExchange() {
|
|
41
|
+
if (!ticket)
|
|
42
|
+
return;
|
|
43
|
+
if (processedTickets.has(ticket))
|
|
44
|
+
return;
|
|
45
|
+
processedTickets.add(ticket);
|
|
46
|
+
try {
|
|
47
|
+
const data = await (0, ticket_1.exchangeTicket)({ apiBase: base, ticket, clientId });
|
|
48
|
+
(0, ticket_1.persistTicketAuth)(data.accessToken, data.refreshToken, data.user);
|
|
49
|
+
(0, ticket_1.broadcastTicketLogin)(data.accessToken, data.user);
|
|
50
|
+
fetchProfile(data.accessToken);
|
|
51
|
+
setState('success');
|
|
52
|
+
setTimeout(() => { window.location.href = next; }, 500);
|
|
53
|
+
}
|
|
54
|
+
catch (e) {
|
|
55
|
+
processedTickets.delete(ticket);
|
|
56
|
+
setState('error');
|
|
57
|
+
setError(e.message || 'Gagal menukar ticket.');
|
|
58
|
+
}
|
|
35
59
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
42
|
-
credentials: 'include',
|
|
43
|
-
body: JSON.stringify({ token, refreshToken }),
|
|
44
|
-
}).catch(() => { });
|
|
45
|
-
fetch(`${base}/auth/me`, {
|
|
46
|
-
headers: { Authorization: `Bearer ${token}` },
|
|
47
|
-
credentials: 'include',
|
|
48
|
-
}).then(r => r.json()).then(d => {
|
|
49
|
-
const p = d?.data ?? d;
|
|
50
|
-
if (p) {
|
|
51
|
-
localStorage.setItem('sms_user_profile', JSON.stringify(p));
|
|
52
|
-
document.cookie = `sms_user_profile=${encodeURIComponent(JSON.stringify(p))}; path=/; SameSite=Lax`;
|
|
60
|
+
function handleLegacyToken() {
|
|
61
|
+
if (!token) {
|
|
62
|
+
setState('error');
|
|
63
|
+
setError('Parameter tidak valid. Ticket atau token diperlukan.');
|
|
64
|
+
return;
|
|
53
65
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
66
|
+
(0, ticket_1.persistTicketAuth)(token, refreshToken || undefined);
|
|
67
|
+
(0, ticket_1.broadcastTicketLogin)(token);
|
|
68
|
+
fetch(`${base}/auth/set-cookie`, {
|
|
69
|
+
method: 'POST',
|
|
70
|
+
headers: { 'Content-Type': 'application/json' },
|
|
71
|
+
credentials: 'include',
|
|
72
|
+
body: JSON.stringify({ token, refreshToken }),
|
|
73
|
+
}).catch(() => { });
|
|
74
|
+
fetchProfile(token);
|
|
75
|
+
setState('success');
|
|
76
|
+
setTimeout(() => { window.location.href = next; }, 500);
|
|
77
|
+
}
|
|
78
|
+
if (ticket) {
|
|
79
|
+
handleTicketExchange();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
handleLegacyToken();
|
|
83
|
+
}, [apiBase, clientId, redirectPath]);
|
|
84
|
+
return ((0, jsx_runtime_1.jsx)("div", { className: "flex items-center justify-center min-h-screen", children: (0, jsx_runtime_1.jsxs)("div", { className: "text-center", children: [state === 'loading' && (0, jsx_runtime_1.jsx)("p", { className: "text-gray-500", children: "Menyelesaikan login..." }), state === 'success' && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("p", { className: "text-green-600 font-medium", children: "Login berhasil!" }), (0, jsx_runtime_1.jsx)("p", { className: "text-sm text-gray-500", children: "Mengalihkan..." })] })), state === 'error' && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("p", { className: "text-red-500 mb-2", children: "Login gagal" }), (0, jsx_runtime_1.jsx)("p", { className: "text-sm text-gray-500 mb-4", children: error }), (0, jsx_runtime_1.jsx)("a", { href: loginPath, children: "Kembali ke login" })] }))] }) }));
|
|
60
85
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type IssueTicketOptions, type IssueTicketResponse } from '../ticket';
|
|
2
|
+
export interface UseIssueTicketState {
|
|
3
|
+
ticket: IssueTicketResponse | null;
|
|
4
|
+
loading: boolean;
|
|
5
|
+
error: string | null;
|
|
6
|
+
issue: (options?: Omit<IssueTicketOptions, 'apiBase'>) => Promise<IssueTicketResponse>;
|
|
7
|
+
}
|
|
8
|
+
export declare function useIssueTicket(apiBase?: string): UseIssueTicketState;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.useIssueTicket = useIssueTicket;
|
|
4
|
+
const react_1 = require("react");
|
|
5
|
+
const ticket_1 = require("../ticket");
|
|
6
|
+
function useIssueTicket(apiBase = '/api') {
|
|
7
|
+
const [ticket, setTicket] = (0, react_1.useState)(null);
|
|
8
|
+
const [loading, setLoading] = (0, react_1.useState)(false);
|
|
9
|
+
const [error, setError] = (0, react_1.useState)(null);
|
|
10
|
+
const issue = (0, react_1.useCallback)(async (options = {}) => {
|
|
11
|
+
setLoading(true);
|
|
12
|
+
setError(null);
|
|
13
|
+
try {
|
|
14
|
+
const result = await (0, ticket_1.issueTicket)({ ...options, apiBase });
|
|
15
|
+
setTicket(result);
|
|
16
|
+
return result;
|
|
17
|
+
}
|
|
18
|
+
catch (err) {
|
|
19
|
+
const message = err?.message || 'Gagal membuat ticket SSO.';
|
|
20
|
+
setError(message);
|
|
21
|
+
throw err;
|
|
22
|
+
}
|
|
23
|
+
finally {
|
|
24
|
+
setLoading(false);
|
|
25
|
+
}
|
|
26
|
+
}, [apiBase]);
|
|
27
|
+
return { ticket, loading, error, issue };
|
|
28
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type NavigateToAppOptions } from '../navigation';
|
|
2
|
+
export interface UseNavigateToAppState {
|
|
3
|
+
loading: boolean;
|
|
4
|
+
error: string | null;
|
|
5
|
+
navigate: (options: Omit<NavigateToAppOptions, 'apiBase'>) => Promise<void>;
|
|
6
|
+
}
|
|
7
|
+
export declare function useNavigateToApp(apiBase?: string): UseNavigateToAppState;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.useNavigateToApp = useNavigateToApp;
|
|
4
|
+
const react_1 = require("react");
|
|
5
|
+
const navigation_1 = require("../navigation");
|
|
6
|
+
function useNavigateToApp(apiBase = '/api') {
|
|
7
|
+
const [loading, setLoading] = (0, react_1.useState)(false);
|
|
8
|
+
const [error, setError] = (0, react_1.useState)(null);
|
|
9
|
+
const navigate = (0, react_1.useCallback)(async (options) => {
|
|
10
|
+
setLoading(true);
|
|
11
|
+
setError(null);
|
|
12
|
+
try {
|
|
13
|
+
await (0, navigation_1.navigateToApp)({ ...options, apiBase });
|
|
14
|
+
}
|
|
15
|
+
catch (err) {
|
|
16
|
+
const message = err?.message || 'Gagal membuka aplikasi tujuan.';
|
|
17
|
+
setError(message);
|
|
18
|
+
throw err;
|
|
19
|
+
}
|
|
20
|
+
finally {
|
|
21
|
+
setLoading(false);
|
|
22
|
+
}
|
|
23
|
+
}, [apiBase]);
|
|
24
|
+
return { loading, error, navigate };
|
|
25
|
+
}
|
|
@@ -50,7 +50,7 @@ function useOAuthCallback(input) {
|
|
|
50
50
|
setState({ error: null, loading: true, phase: 'exchange' });
|
|
51
51
|
const codeVerifier = sessionStorage.getItem(`oauth_verifier_${oauthState || ''}`) || '';
|
|
52
52
|
sessionStorage.removeItem(`oauth_verifier_${oauthState || ''}`);
|
|
53
|
-
|
|
53
|
+
const res = await fetch(`${apiBase}/oauth/token`, {
|
|
54
54
|
method: 'POST',
|
|
55
55
|
headers: { 'Content-Type': 'application/json' },
|
|
56
56
|
credentials: 'include',
|
|
@@ -62,15 +62,6 @@ function useOAuthCallback(input) {
|
|
|
62
62
|
redirect_uri: `${window.location.origin}${window.location.pathname}`,
|
|
63
63
|
}),
|
|
64
64
|
});
|
|
65
|
-
// Development fallback: retry without code_verifier if PKCE fails
|
|
66
|
-
if (!res.ok && !codeVerifier) {
|
|
67
|
-
res = await fetch(`${apiBase}/oauth/token`, {
|
|
68
|
-
method: 'POST',
|
|
69
|
-
headers: { 'Content-Type': 'application/json' },
|
|
70
|
-
credentials: 'include',
|
|
71
|
-
body: JSON.stringify({ grant_type: 'authorization_code', code, client_id: input.clientId, redirect_uri: `${window.location.origin}${window.location.pathname}` }),
|
|
72
|
-
});
|
|
73
|
-
}
|
|
74
65
|
if (!res.ok)
|
|
75
66
|
throw new Error('Token exchange failed');
|
|
76
67
|
const d = await res.json();
|
package/dist/index.d.ts
CHANGED
|
@@ -4,9 +4,19 @@ export { hasSsoToken, redirectToOAuthLogin } from './oauthRedirect';
|
|
|
4
4
|
export type { OAuthRedirectOptions } from './oauthRedirect';
|
|
5
5
|
export { SSO_STORAGE_KEYS, SSO_CHANNELS, API_HEADERS, } from './constants';
|
|
6
6
|
export { createAuthInterceptor } from './interceptor';
|
|
7
|
+
export { issueTicket, exchangeTicket, getStoredAccessToken, decodeUserFromToken, persistTicketAuth, broadcastTicketLogin, } from './ticket';
|
|
8
|
+
export type { IssueTicketOptions, IssueTicketResponse, ExchangeTicketOptions, ExchangeTicketResponse } from './ticket';
|
|
9
|
+
export { buildTransferUrl, navigateToApp } from './navigation';
|
|
10
|
+
export type { NavigateToAppOptions } from './navigation';
|
|
7
11
|
export { useOAuthCallback } from './hooks/useOAuthCallback';
|
|
8
12
|
export { useCrossAppLogout } from './hooks/useCrossAppLogout';
|
|
13
|
+
export { useIssueTicket } from './hooks/useIssueTicket';
|
|
14
|
+
export type { UseIssueTicketState } from './hooks/useIssueTicket';
|
|
15
|
+
export { useNavigateToApp } from './hooks/useNavigateToApp';
|
|
16
|
+
export type { UseNavigateToAppState } from './hooks/useNavigateToApp';
|
|
9
17
|
export { OAuthCallback } from './components/OAuthCallback';
|
|
10
18
|
export type { OAuthCallbackProps } from './components/OAuthCallback';
|
|
11
19
|
export { OAuthTransfer } from './components/OAuthTransfer';
|
|
12
20
|
export type { OAuthTransferProps } from './components/OAuthTransfer';
|
|
21
|
+
export { AuthApp, ProtectedRoute, useAuth } from './auth';
|
|
22
|
+
export type { AuthAppProps, AuthUser } from './auth';
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.OAuthTransfer = exports.OAuthCallback = exports.useCrossAppLogout = exports.useOAuthCallback = exports.createAuthInterceptor = exports.API_HEADERS = exports.SSO_CHANNELS = exports.SSO_STORAGE_KEYS = exports.redirectToOAuthLogin = exports.hasSsoToken = exports.generateState = exports.generateCodeChallenge = exports.generateCodeVerifier = exports.sha256 = void 0;
|
|
3
|
+
exports.useAuth = exports.ProtectedRoute = exports.AuthApp = exports.OAuthTransfer = exports.OAuthCallback = exports.useNavigateToApp = exports.useIssueTicket = exports.useCrossAppLogout = exports.useOAuthCallback = exports.navigateToApp = exports.buildTransferUrl = exports.broadcastTicketLogin = exports.persistTicketAuth = exports.decodeUserFromToken = exports.getStoredAccessToken = exports.exchangeTicket = exports.issueTicket = exports.createAuthInterceptor = exports.API_HEADERS = exports.SSO_CHANNELS = exports.SSO_STORAGE_KEYS = exports.redirectToOAuthLogin = exports.hasSsoToken = exports.generateState = exports.generateCodeChallenge = exports.generateCodeVerifier = exports.sha256 = void 0;
|
|
4
4
|
var sha256_1 = require("./sha256");
|
|
5
5
|
Object.defineProperty(exports, "sha256", { enumerable: true, get: function () { return sha256_1.sha256; } });
|
|
6
6
|
var pkce_1 = require("./pkce");
|
|
@@ -16,11 +16,29 @@ Object.defineProperty(exports, "SSO_CHANNELS", { enumerable: true, get: function
|
|
|
16
16
|
Object.defineProperty(exports, "API_HEADERS", { enumerable: true, get: function () { return constants_1.API_HEADERS; } });
|
|
17
17
|
var interceptor_1 = require("./interceptor");
|
|
18
18
|
Object.defineProperty(exports, "createAuthInterceptor", { enumerable: true, get: function () { return interceptor_1.createAuthInterceptor; } });
|
|
19
|
+
var ticket_1 = require("./ticket");
|
|
20
|
+
Object.defineProperty(exports, "issueTicket", { enumerable: true, get: function () { return ticket_1.issueTicket; } });
|
|
21
|
+
Object.defineProperty(exports, "exchangeTicket", { enumerable: true, get: function () { return ticket_1.exchangeTicket; } });
|
|
22
|
+
Object.defineProperty(exports, "getStoredAccessToken", { enumerable: true, get: function () { return ticket_1.getStoredAccessToken; } });
|
|
23
|
+
Object.defineProperty(exports, "decodeUserFromToken", { enumerable: true, get: function () { return ticket_1.decodeUserFromToken; } });
|
|
24
|
+
Object.defineProperty(exports, "persistTicketAuth", { enumerable: true, get: function () { return ticket_1.persistTicketAuth; } });
|
|
25
|
+
Object.defineProperty(exports, "broadcastTicketLogin", { enumerable: true, get: function () { return ticket_1.broadcastTicketLogin; } });
|
|
26
|
+
var navigation_1 = require("./navigation");
|
|
27
|
+
Object.defineProperty(exports, "buildTransferUrl", { enumerable: true, get: function () { return navigation_1.buildTransferUrl; } });
|
|
28
|
+
Object.defineProperty(exports, "navigateToApp", { enumerable: true, get: function () { return navigation_1.navigateToApp; } });
|
|
19
29
|
var useOAuthCallback_1 = require("./hooks/useOAuthCallback");
|
|
20
30
|
Object.defineProperty(exports, "useOAuthCallback", { enumerable: true, get: function () { return useOAuthCallback_1.useOAuthCallback; } });
|
|
21
31
|
var useCrossAppLogout_1 = require("./hooks/useCrossAppLogout");
|
|
22
32
|
Object.defineProperty(exports, "useCrossAppLogout", { enumerable: true, get: function () { return useCrossAppLogout_1.useCrossAppLogout; } });
|
|
33
|
+
var useIssueTicket_1 = require("./hooks/useIssueTicket");
|
|
34
|
+
Object.defineProperty(exports, "useIssueTicket", { enumerable: true, get: function () { return useIssueTicket_1.useIssueTicket; } });
|
|
35
|
+
var useNavigateToApp_1 = require("./hooks/useNavigateToApp");
|
|
36
|
+
Object.defineProperty(exports, "useNavigateToApp", { enumerable: true, get: function () { return useNavigateToApp_1.useNavigateToApp; } });
|
|
23
37
|
var OAuthCallback_1 = require("./components/OAuthCallback");
|
|
24
38
|
Object.defineProperty(exports, "OAuthCallback", { enumerable: true, get: function () { return OAuthCallback_1.OAuthCallback; } });
|
|
25
39
|
var OAuthTransfer_1 = require("./components/OAuthTransfer");
|
|
26
40
|
Object.defineProperty(exports, "OAuthTransfer", { enumerable: true, get: function () { return OAuthTransfer_1.OAuthTransfer; } });
|
|
41
|
+
var auth_1 = require("./auth");
|
|
42
|
+
Object.defineProperty(exports, "AuthApp", { enumerable: true, get: function () { return auth_1.AuthApp; } });
|
|
43
|
+
Object.defineProperty(exports, "ProtectedRoute", { enumerable: true, get: function () { return auth_1.ProtectedRoute; } });
|
|
44
|
+
Object.defineProperty(exports, "useAuth", { enumerable: true, get: function () { return auth_1.useAuth; } });
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface NavigateToAppOptions {
|
|
2
|
+
targetUrl: string;
|
|
3
|
+
apiBase?: string;
|
|
4
|
+
accessToken?: string;
|
|
5
|
+
clientId?: string;
|
|
6
|
+
targetBlank?: boolean;
|
|
7
|
+
next?: string;
|
|
8
|
+
fallbackToPlainUrl?: boolean;
|
|
9
|
+
}
|
|
10
|
+
export declare function buildTransferUrl(targetUrl: string, ticket: string, next?: string): string;
|
|
11
|
+
export declare function navigateToApp({ targetUrl, apiBase, accessToken, clientId, targetBlank, next, fallbackToPlainUrl, }: NavigateToAppOptions): Promise<void>;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.buildTransferUrl = buildTransferUrl;
|
|
4
|
+
exports.navigateToApp = navigateToApp;
|
|
5
|
+
const ticket_1 = require("./ticket");
|
|
6
|
+
function resolveUrl(url) {
|
|
7
|
+
return new URL(url, window.location.origin);
|
|
8
|
+
}
|
|
9
|
+
function buildTransferUrl(targetUrl, ticket, next) {
|
|
10
|
+
const target = resolveUrl(targetUrl);
|
|
11
|
+
const transferUrl = new URL('/auth/transfer', target.origin);
|
|
12
|
+
const nextPath = next || `${target.pathname}${target.search}${target.hash}` || '/';
|
|
13
|
+
transferUrl.searchParams.set('ticket', ticket);
|
|
14
|
+
transferUrl.searchParams.set('next', nextPath || '/');
|
|
15
|
+
return transferUrl.toString();
|
|
16
|
+
}
|
|
17
|
+
function openDestination(url, targetBlank, existingTab) {
|
|
18
|
+
if (targetBlank) {
|
|
19
|
+
if (existingTab) {
|
|
20
|
+
existingTab.location.href = url;
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
window.open(url, '_blank', 'noopener,noreferrer');
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
window.location.href = url;
|
|
27
|
+
}
|
|
28
|
+
async function navigateToApp({ targetUrl, apiBase = '/api', accessToken, clientId, targetBlank = true, next, fallbackToPlainUrl = true, }) {
|
|
29
|
+
const token = accessToken || (0, ticket_1.getStoredAccessToken)();
|
|
30
|
+
if (!token) {
|
|
31
|
+
openDestination(targetUrl, targetBlank);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
let newTab = null;
|
|
35
|
+
if (targetBlank) {
|
|
36
|
+
newTab = window.open('about:blank', '_blank');
|
|
37
|
+
if (!newTab) {
|
|
38
|
+
window.location.href = targetUrl;
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
const issued = await (0, ticket_1.issueTicket)({
|
|
44
|
+
apiBase,
|
|
45
|
+
accessToken: token,
|
|
46
|
+
clientId,
|
|
47
|
+
redirect: targetUrl,
|
|
48
|
+
});
|
|
49
|
+
openDestination(buildTransferUrl(targetUrl, issued.ticket, next), targetBlank, newTab);
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
if (!fallbackToPlainUrl)
|
|
53
|
+
throw error;
|
|
54
|
+
openDestination(targetUrl, targetBlank, newTab);
|
|
55
|
+
}
|
|
56
|
+
}
|
package/dist/ticket.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export interface IssueTicketOptions {
|
|
2
|
+
apiBase?: string;
|
|
3
|
+
accessToken?: string;
|
|
4
|
+
clientId?: string;
|
|
5
|
+
redirect?: string;
|
|
6
|
+
signal?: AbortSignal;
|
|
7
|
+
}
|
|
8
|
+
export interface IssueTicketResponse {
|
|
9
|
+
ticket: string;
|
|
10
|
+
expiresAt?: number | string;
|
|
11
|
+
expiresIn: number;
|
|
12
|
+
}
|
|
13
|
+
export interface ExchangeTicketOptions {
|
|
14
|
+
apiBase?: string;
|
|
15
|
+
ticket: string;
|
|
16
|
+
clientId?: string;
|
|
17
|
+
signal?: AbortSignal;
|
|
18
|
+
}
|
|
19
|
+
export interface ExchangeTicketResponse {
|
|
20
|
+
accessToken: string;
|
|
21
|
+
refreshToken: string;
|
|
22
|
+
user?: any;
|
|
23
|
+
}
|
|
24
|
+
export declare function getStoredAccessToken(): string;
|
|
25
|
+
export declare function issueTicket(options?: IssueTicketOptions): Promise<IssueTicketResponse>;
|
|
26
|
+
export declare function exchangeTicket(options: ExchangeTicketOptions): Promise<ExchangeTicketResponse>;
|
|
27
|
+
export declare function decodeUserFromToken(token: string): any | null;
|
|
28
|
+
export declare function persistTicketAuth(accessToken: string, refreshToken?: string, user?: any): void;
|
|
29
|
+
export declare function broadcastTicketLogin(accessToken: string, user?: any): void;
|
package/dist/ticket.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.getStoredAccessToken = getStoredAccessToken;
|
|
4
|
+
exports.issueTicket = issueTicket;
|
|
5
|
+
exports.exchangeTicket = exchangeTicket;
|
|
6
|
+
exports.decodeUserFromToken = decodeUserFromToken;
|
|
7
|
+
exports.persistTicketAuth = persistTicketAuth;
|
|
8
|
+
exports.broadcastTicketLogin = broadcastTicketLogin;
|
|
9
|
+
const constants_1 = require("./constants");
|
|
10
|
+
function trimTrailingSlash(value) {
|
|
11
|
+
return value.replace(/\/+$/, '');
|
|
12
|
+
}
|
|
13
|
+
function getCookie(name) {
|
|
14
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
15
|
+
const match = document.cookie.match(new RegExp(`(^| )${escaped}=([^;]+)`));
|
|
16
|
+
return match ? decodeURIComponent(match[2]) : '';
|
|
17
|
+
}
|
|
18
|
+
function getStoredAccessToken() {
|
|
19
|
+
return localStorage.getItem(constants_1.SSO_STORAGE_KEYS.ACCESS_TOKEN) || getCookie(constants_1.SSO_STORAGE_KEYS.ACCESS_TOKEN);
|
|
20
|
+
}
|
|
21
|
+
function getApiBase(apiBase) {
|
|
22
|
+
return trimTrailingSlash(apiBase || '/api');
|
|
23
|
+
}
|
|
24
|
+
async function parseJsonResponse(response) {
|
|
25
|
+
const body = await response.json().catch(() => ({}));
|
|
26
|
+
if (!response.ok) {
|
|
27
|
+
throw new Error(body?.message || body?.error || `Request failed (${response.status})`);
|
|
28
|
+
}
|
|
29
|
+
return body?.data ?? body;
|
|
30
|
+
}
|
|
31
|
+
async function issueTicket(options = {}) {
|
|
32
|
+
const accessToken = options.accessToken || getStoredAccessToken();
|
|
33
|
+
if (!accessToken)
|
|
34
|
+
throw new Error('Not authenticated');
|
|
35
|
+
const response = await fetch(`${getApiBase(options.apiBase)}/sso/issue-ticket`, {
|
|
36
|
+
method: 'POST',
|
|
37
|
+
headers: {
|
|
38
|
+
'Content-Type': 'application/json',
|
|
39
|
+
Authorization: `Bearer ${accessToken}`,
|
|
40
|
+
},
|
|
41
|
+
credentials: 'include',
|
|
42
|
+
signal: options.signal,
|
|
43
|
+
body: JSON.stringify({
|
|
44
|
+
client_id: options.clientId,
|
|
45
|
+
redirect: options.redirect,
|
|
46
|
+
}),
|
|
47
|
+
});
|
|
48
|
+
const data = await parseJsonResponse(response);
|
|
49
|
+
if (!data?.ticket)
|
|
50
|
+
throw new Error('No ticket returned');
|
|
51
|
+
return {
|
|
52
|
+
ticket: data.ticket,
|
|
53
|
+
expiresAt: data.expiresAt,
|
|
54
|
+
expiresIn: Number(data.expiresIn || 60),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
async function exchangeTicket(options) {
|
|
58
|
+
if (!options.ticket)
|
|
59
|
+
throw new Error('ticket required');
|
|
60
|
+
const response = await fetch(`${getApiBase(options.apiBase)}/sso/exchange`, {
|
|
61
|
+
method: 'POST',
|
|
62
|
+
headers: { 'Content-Type': 'application/json' },
|
|
63
|
+
credentials: 'include',
|
|
64
|
+
signal: options.signal,
|
|
65
|
+
body: JSON.stringify({ ticket: options.ticket, clientId: options.clientId }),
|
|
66
|
+
});
|
|
67
|
+
const data = await parseJsonResponse(response);
|
|
68
|
+
const accessToken = data.accessToken || data.token || data.access_token;
|
|
69
|
+
const refreshToken = data.refreshToken || data.refresh_token;
|
|
70
|
+
if (!accessToken || !refreshToken)
|
|
71
|
+
throw new Error('Token tidak ditemukan dalam response exchange.');
|
|
72
|
+
return { accessToken, refreshToken, user: data.user };
|
|
73
|
+
}
|
|
74
|
+
function decodeUserFromToken(token) {
|
|
75
|
+
try {
|
|
76
|
+
const payload = JSON.parse(atob(token.split('.')[1]));
|
|
77
|
+
return {
|
|
78
|
+
sub: payload.sub,
|
|
79
|
+
id: payload.sub,
|
|
80
|
+
email: payload.email,
|
|
81
|
+
fullName: payload.fullName,
|
|
82
|
+
name: payload.fullName || payload.name || payload.email,
|
|
83
|
+
role: payload.role,
|
|
84
|
+
schoolId: payload.schoolId,
|
|
85
|
+
schoolType: payload.schoolType,
|
|
86
|
+
forcePasswordChange: payload.forcePasswordChange,
|
|
87
|
+
features: payload.features || [],
|
|
88
|
+
accessibleSchools: payload.accessibleSchools || [],
|
|
89
|
+
capabilities: payload.capabilities || [],
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function persistTicketAuth(accessToken, refreshToken, user) {
|
|
97
|
+
localStorage.setItem(constants_1.SSO_STORAGE_KEYS.ACCESS_TOKEN, accessToken);
|
|
98
|
+
if (refreshToken)
|
|
99
|
+
localStorage.setItem(constants_1.SSO_STORAGE_KEYS.REFRESH_TOKEN, refreshToken);
|
|
100
|
+
const profile = user || decodeUserFromToken(accessToken);
|
|
101
|
+
if (profile) {
|
|
102
|
+
localStorage.setItem(constants_1.SSO_STORAGE_KEYS.USER_PROFILE, JSON.stringify(profile));
|
|
103
|
+
localStorage.setItem('lms_user', JSON.stringify(profile));
|
|
104
|
+
localStorage.setItem(constants_1.SSO_STORAGE_KEYS.USER_ROLE, (profile.role || '').toLowerCase());
|
|
105
|
+
localStorage.setItem(constants_1.SSO_STORAGE_KEYS.USER_ID, profile.sub || profile.id || '');
|
|
106
|
+
if (profile.schoolId)
|
|
107
|
+
localStorage.setItem(constants_1.SSO_STORAGE_KEYS.SCHOOL_ID, profile.schoolId);
|
|
108
|
+
document.cookie = `sms_user_profile=${encodeURIComponent(JSON.stringify(profile))}; path=/; SameSite=Lax`;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function broadcastTicketLogin(accessToken, user) {
|
|
112
|
+
try {
|
|
113
|
+
const channel = new BroadcastChannel(constants_1.SSO_CHANNELS.LOGIN);
|
|
114
|
+
channel.postMessage({ token: accessToken, user });
|
|
115
|
+
channel.close();
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// BroadcastChannel is optional.
|
|
119
|
+
}
|
|
120
|
+
}
|