@mahulu/sso-client 1.0.0
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 +401 -0
- package/package.json +50 -0
- package/src/index.d.ts +123 -0
- package/src/index.js +397 -0
package/README.md
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
# SSO Mahulu - Node.js Client Package
|
|
2
|
+
|
|
3
|
+
> Node.js SDK untuk integrasi OAuth 2.0 dengan SSO Mahulu (Identity Provider Pemerintah Mahulu Hulu)
|
|
4
|
+
|
|
5
|
+
## Persyaratan
|
|
6
|
+
|
|
7
|
+
- Node.js >= 16.x
|
|
8
|
+
- OAuth Client Credentials dari admin SSO Mahulu
|
|
9
|
+
|
|
10
|
+
## Instalasi
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm install @mahulu/sso-client
|
|
14
|
+
|
|
15
|
+
# Jika menggunakan Express.js:
|
|
16
|
+
npm install express express-session
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Quick Start (Express.js)
|
|
20
|
+
|
|
21
|
+
### 1. Setup Express + SSO
|
|
22
|
+
|
|
23
|
+
```javascript
|
|
24
|
+
const express = require('express');
|
|
25
|
+
const session = require('express-session');
|
|
26
|
+
const { createExpressMiddleware } = require('@mahulu/sso-client');
|
|
27
|
+
|
|
28
|
+
const app = express();
|
|
29
|
+
|
|
30
|
+
// Setup session (wajib)
|
|
31
|
+
app.use(
|
|
32
|
+
session({
|
|
33
|
+
secret: 'your-random-secret-key',
|
|
34
|
+
resave: false,
|
|
35
|
+
saveUninitialized: false,
|
|
36
|
+
cookie: { secure: process.env.NODE_ENV === 'production' },
|
|
37
|
+
}),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
// Setup SSO
|
|
41
|
+
const sso = createExpressMiddleware({
|
|
42
|
+
baseUrl: process.env.SSO_MAHULU_BASE_URL, // https://sso.mahulu.go.id
|
|
43
|
+
clientId: process.env.SSO_MAHULU_CLIENT_ID, // dari admin SSO
|
|
44
|
+
clientSecret: process.env.SSO_MAHULU_CLIENT_SECRET, // dari admin SSO
|
|
45
|
+
redirectUri: process.env.SSO_MAHULU_REDIRECT_URI, // http://localhost:3000/auth/sso/callback
|
|
46
|
+
redirectAfterLogin: '/dashboard',
|
|
47
|
+
redirectOnError: '/login',
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Register routes
|
|
51
|
+
app.get('/auth/sso/redirect', sso.redirect); // Redirect ke SSO
|
|
52
|
+
app.get('/auth/sso/callback', sso.callback); // Handle callback
|
|
53
|
+
app.post('/auth/sso/logout', sso.logout); // Logout
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### 2. Environment Variables
|
|
57
|
+
|
|
58
|
+
Buat file `.env`:
|
|
59
|
+
|
|
60
|
+
```env
|
|
61
|
+
SSO_MAHULU_BASE_URL=https://sso.mahulu.go.id
|
|
62
|
+
SSO_MAHULU_CLIENT_ID=your-client-id-dari-admin
|
|
63
|
+
SSO_MAHULU_CLIENT_SECRET=your-client-secret-dari-admin
|
|
64
|
+
SSO_MAHULU_REDIRECT_URI=http://localhost:3000/auth/sso/callback
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### 3. Tombol Login di Frontend
|
|
68
|
+
|
|
69
|
+
```html
|
|
70
|
+
<a href="/auth/sso/redirect">Login dengan SSO Mahulu</a>
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### 4. Protected Routes
|
|
74
|
+
|
|
75
|
+
```javascript
|
|
76
|
+
// Middleware auth check
|
|
77
|
+
function requireAuth(req, res, next) {
|
|
78
|
+
if (!req.session.ssoUser) {
|
|
79
|
+
return res.redirect('/login');
|
|
80
|
+
}
|
|
81
|
+
next();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Protected route + auto-refresh token
|
|
85
|
+
app.get('/dashboard', requireAuth, sso.ensureTokenValid, (req, res) => {
|
|
86
|
+
const user = req.session.ssoUser;
|
|
87
|
+
res.json({ message: `Selamat datang, ${user.name}!`, user });
|
|
88
|
+
});
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### 5. Logout
|
|
92
|
+
|
|
93
|
+
```html
|
|
94
|
+
<form
|
|
95
|
+
method="POST"
|
|
96
|
+
action="/auth/sso/logout"
|
|
97
|
+
>
|
|
98
|
+
<button type="submit">Logout</button>
|
|
99
|
+
</form>
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Penggunaan Lanjutan
|
|
103
|
+
|
|
104
|
+
### Custom onSuccess Handler
|
|
105
|
+
|
|
106
|
+
Gunakan `onSuccess` untuk menyimpan user ke database:
|
|
107
|
+
|
|
108
|
+
```javascript
|
|
109
|
+
const sso = createExpressMiddleware({
|
|
110
|
+
// ... config
|
|
111
|
+
onSuccess: async (req, user, tokens) => {
|
|
112
|
+
// Simpan/update user di database
|
|
113
|
+
const dbUser = await User.findOneAndUpdate(
|
|
114
|
+
{ nip: user.nip },
|
|
115
|
+
{
|
|
116
|
+
name: user.name,
|
|
117
|
+
email: user.email,
|
|
118
|
+
nip: user.nip,
|
|
119
|
+
ssoAccessToken: tokens.access_token,
|
|
120
|
+
ssoRefreshToken: tokens.refresh_token,
|
|
121
|
+
tokenExpiresAt: new Date(Date.now() + tokens.expires_in * 1000),
|
|
122
|
+
},
|
|
123
|
+
{ upsert: true, new: true },
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
// Simpan di session
|
|
127
|
+
req.session.user = dbUser;
|
|
128
|
+
req.session.ssoTokens = {
|
|
129
|
+
accessToken: tokens.access_token,
|
|
130
|
+
refreshToken: tokens.refresh_token,
|
|
131
|
+
expiresAt: new Date(Date.now() + tokens.expires_in * 1000).toISOString(),
|
|
132
|
+
};
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Custom onError Handler
|
|
138
|
+
|
|
139
|
+
```javascript
|
|
140
|
+
const sso = createExpressMiddleware({
|
|
141
|
+
// ... config
|
|
142
|
+
onError: (req, res, error) => {
|
|
143
|
+
console.error('[SSO Error]', error.message);
|
|
144
|
+
|
|
145
|
+
// Render error page atau redirect
|
|
146
|
+
res.status(400).render('login', {
|
|
147
|
+
error: 'Login SSO gagal. Silakan coba lagi.',
|
|
148
|
+
});
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Menggunakan SsoMahuluClient Langsung (Tanpa Express)
|
|
154
|
+
|
|
155
|
+
Untuk framework lain (Fastify, Hapi, NestJS, dll):
|
|
156
|
+
|
|
157
|
+
```javascript
|
|
158
|
+
const { SsoMahuluClient } = require('@mahulu/sso-client');
|
|
159
|
+
|
|
160
|
+
const sso = new SsoMahuluClient({
|
|
161
|
+
baseUrl: 'https://sso.mahulu.go.id',
|
|
162
|
+
clientId: 'your-client-id',
|
|
163
|
+
clientSecret: 'your-client-secret',
|
|
164
|
+
redirectUri: 'http://localhost:3000/auth/sso/callback',
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// 1. Generate state & redirect URL
|
|
168
|
+
const state = sso.generateState();
|
|
169
|
+
// Simpan state di session/store
|
|
170
|
+
const authUrl = sso.getAuthorizationUrl(state);
|
|
171
|
+
// Redirect user ke authUrl
|
|
172
|
+
|
|
173
|
+
// 2. Di callback handler
|
|
174
|
+
const tokens = await sso.exchangeCodeForToken(code);
|
|
175
|
+
|
|
176
|
+
// 3. Get user info
|
|
177
|
+
const user = await sso.getUserInfo(tokens.access_token);
|
|
178
|
+
|
|
179
|
+
// 4. Refresh token jika expired
|
|
180
|
+
if (sso.isTokenExpired(storedExpiresAt)) {
|
|
181
|
+
const newTokens = await sso.refreshToken(storedRefreshToken);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// 5. Logout URL
|
|
185
|
+
const logoutUrl = sso.getLogoutUrl();
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### Integrasi dengan NestJS
|
|
189
|
+
|
|
190
|
+
```typescript
|
|
191
|
+
// sso.module.ts
|
|
192
|
+
import { Module } from '@nestjs/common';
|
|
193
|
+
import { SsoMahuluClient } from '@mahulu/sso-client';
|
|
194
|
+
|
|
195
|
+
@Module({
|
|
196
|
+
providers: [
|
|
197
|
+
{
|
|
198
|
+
provide: 'SSO_CLIENT',
|
|
199
|
+
useFactory: () =>
|
|
200
|
+
new SsoMahuluClient({
|
|
201
|
+
baseUrl: process.env.SSO_MAHULU_BASE_URL,
|
|
202
|
+
clientId: process.env.SSO_MAHULU_CLIENT_ID,
|
|
203
|
+
clientSecret: process.env.SSO_MAHULU_CLIENT_SECRET,
|
|
204
|
+
redirectUri: process.env.SSO_MAHULU_REDIRECT_URI,
|
|
205
|
+
}),
|
|
206
|
+
},
|
|
207
|
+
],
|
|
208
|
+
exports: ['SSO_CLIENT'],
|
|
209
|
+
})
|
|
210
|
+
export class SsoModule {}
|
|
211
|
+
|
|
212
|
+
// sso.controller.ts
|
|
213
|
+
import { Controller, Get, Inject, Query, Req, Res, Session } from '@nestjs/common';
|
|
214
|
+
import { SsoMahuluClient } from '@mahulu/sso-client';
|
|
215
|
+
|
|
216
|
+
@Controller('auth/sso')
|
|
217
|
+
export class SsoController {
|
|
218
|
+
constructor(@Inject('SSO_CLIENT') private sso: SsoMahuluClient) {}
|
|
219
|
+
|
|
220
|
+
@Get('redirect')
|
|
221
|
+
redirect(@Session() session: any, @Res() res: any) {
|
|
222
|
+
const state = this.sso.generateState();
|
|
223
|
+
session.ssoState = state;
|
|
224
|
+
res.redirect(this.sso.getAuthorizationUrl(state));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
@Get('callback')
|
|
228
|
+
async callback(
|
|
229
|
+
@Query('code') code: string,
|
|
230
|
+
@Query('state') state: string,
|
|
231
|
+
@Session() session: any,
|
|
232
|
+
@Res() res: any,
|
|
233
|
+
) {
|
|
234
|
+
if (state !== session.ssoState) {
|
|
235
|
+
return res.redirect('/login?error=invalid_state');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const tokens = await this.sso.exchangeCodeForToken(code);
|
|
239
|
+
const user = await this.sso.getUserInfo(tokens.access_token);
|
|
240
|
+
|
|
241
|
+
session.user = user;
|
|
242
|
+
session.tokens = tokens;
|
|
243
|
+
|
|
244
|
+
res.redirect('/dashboard');
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
### Integrasi dengan Next.js (API Routes)
|
|
250
|
+
|
|
251
|
+
```javascript
|
|
252
|
+
// pages/api/auth/sso/redirect.js
|
|
253
|
+
import { SsoMahuluClient } from '@mahulu/sso-client';
|
|
254
|
+
|
|
255
|
+
const sso = new SsoMahuluClient({
|
|
256
|
+
baseUrl: process.env.SSO_MAHULU_BASE_URL,
|
|
257
|
+
clientId: process.env.SSO_MAHULU_CLIENT_ID,
|
|
258
|
+
clientSecret: process.env.SSO_MAHULU_CLIENT_SECRET,
|
|
259
|
+
redirectUri: process.env.SSO_MAHULU_REDIRECT_URI,
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
export default function handler(req, res) {
|
|
263
|
+
const state = sso.generateState();
|
|
264
|
+
// Simpan state di cookie
|
|
265
|
+
res.setHeader('Set-Cookie', `sso_state=${state}; Path=/; HttpOnly; SameSite=Lax`);
|
|
266
|
+
res.redirect(302, sso.getAuthorizationUrl(state));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// pages/api/auth/sso/callback.js
|
|
270
|
+
export default async function handler(req, res) {
|
|
271
|
+
const { code, state } = req.query;
|
|
272
|
+
// Validasi state dari cookie...
|
|
273
|
+
|
|
274
|
+
const tokens = await sso.exchangeCodeForToken(code);
|
|
275
|
+
const user = await sso.getUserInfo(tokens.access_token);
|
|
276
|
+
|
|
277
|
+
// Set JWT atau session cookie
|
|
278
|
+
// ...
|
|
279
|
+
|
|
280
|
+
res.redirect(302, '/dashboard');
|
|
281
|
+
}
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
## API Reference
|
|
285
|
+
|
|
286
|
+
### `SsoMahuluClient`
|
|
287
|
+
|
|
288
|
+
| Method | Parameter | Return | Deskripsi |
|
|
289
|
+
| -------------------------------- | ---------------------- | ------------------------ | ------------------------- |
|
|
290
|
+
| `generateState(length?)` | `number` (default: 32) | `string` | Generate random state |
|
|
291
|
+
| `getAuthorizationUrl(state)` | `string` | `string` | Build authorization URL |
|
|
292
|
+
| `exchangeCodeForToken(code)` | `string` | `Promise<TokenResponse>` | Exchange code untuk token |
|
|
293
|
+
| `getUserInfo(accessToken)` | `string` | `Promise<SsoUser>` | Get user info |
|
|
294
|
+
| `refreshToken(refreshToken)` | `string` | `Promise<TokenResponse>` | Refresh expired token |
|
|
295
|
+
| `getLogoutUrl()` | - | `string` | Build SSO logout URL |
|
|
296
|
+
| `isTokenExpired(expiresAt)` | `Date\|string\|number` | `boolean` | Check token expired |
|
|
297
|
+
| `calculateExpiryDate(expiresIn)` | `number` (seconds) | `Date` | Calculate expiry date |
|
|
298
|
+
|
|
299
|
+
### `createExpressMiddleware(options)`
|
|
300
|
+
|
|
301
|
+
Returns object with handlers:
|
|
302
|
+
|
|
303
|
+
| Handler | Tipe | Deskripsi |
|
|
304
|
+
| ------------------ | ------------------------ | -------------------------- |
|
|
305
|
+
| `redirect` | `(req, res)` | Redirect ke SSO login |
|
|
306
|
+
| `callback` | `async (req, res)` | Handle callback SSO |
|
|
307
|
+
| `logout` | `(req, res)` | Logout + redirect ke SSO |
|
|
308
|
+
| `ensureTokenValid` | `async (req, res, next)` | Middleware auto-refresh |
|
|
309
|
+
| `client` | `SsoMahuluClient` | Underlying client instance |
|
|
310
|
+
|
|
311
|
+
### User Data dari SSO
|
|
312
|
+
|
|
313
|
+
```typescript
|
|
314
|
+
interface SsoUser {
|
|
315
|
+
id: number;
|
|
316
|
+
nip: string; // "199001012020121001"
|
|
317
|
+
name: string; // "Budi Santoso"
|
|
318
|
+
email: string; // "budi.santoso@mahulu.go.id"
|
|
319
|
+
username: string; // "199001012020121001"
|
|
320
|
+
is_active: boolean; // true
|
|
321
|
+
api_hub_data: {
|
|
322
|
+
// Data tambahan dari API Hub
|
|
323
|
+
jabatan?: string; // "Kepala Bidang Kepegawaian"
|
|
324
|
+
unit_kerja?: string; // "Badan Kepegawaian Daerah"
|
|
325
|
+
pangkat_golongan?: string;
|
|
326
|
+
eselon?: string;
|
|
327
|
+
} | null;
|
|
328
|
+
created_at: string;
|
|
329
|
+
updated_at: string;
|
|
330
|
+
}
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
### Token Expiration
|
|
334
|
+
|
|
335
|
+
| Token | Durasi |
|
|
336
|
+
| ------------------ | -------- |
|
|
337
|
+
| Access Token | 60 menit |
|
|
338
|
+
| Refresh Token | 14 hari |
|
|
339
|
+
| Authorization Code | 10 menit |
|
|
340
|
+
|
|
341
|
+
## Fitur
|
|
342
|
+
|
|
343
|
+
- **Zero dependencies** - Hanya menggunakan built-in Node.js modules (`http`, `https`, `crypto`)
|
|
344
|
+
- **TypeScript support** - Type definitions included
|
|
345
|
+
- **Express middleware** - Plug-and-play untuk Express.js
|
|
346
|
+
- **Framework agnostic** - `SsoMahuluClient` class bisa digunakan dengan framework apapun
|
|
347
|
+
- **Auto token refresh** - Middleware `ensureTokenValid` otomatis refresh expired token
|
|
348
|
+
- **CSRF protection** - State parameter validation built-in
|
|
349
|
+
|
|
350
|
+
## Mendapatkan OAuth Client Credentials
|
|
351
|
+
|
|
352
|
+
1. Hubungi admin SSO Mahulu di `admin-sso@mahulu.go.id`
|
|
353
|
+
2. Sertakan: nama aplikasi, redirect URI, PIC
|
|
354
|
+
3. Terima `CLIENT_ID` & `CLIENT_SECRET`
|
|
355
|
+
|
|
356
|
+
**PENTING:** `CLIENT_SECRET` hanya ditampilkan SATU KALI. Simpan dengan aman.
|
|
357
|
+
|
|
358
|
+
## Troubleshooting
|
|
359
|
+
|
|
360
|
+
### "Invalid state parameter"
|
|
361
|
+
|
|
362
|
+
- Session mungkin expired. Pastikan `express-session` dikonfigurasi dengan benar.
|
|
363
|
+
- Pastikan `resave: false` dan `saveUninitialized: false`.
|
|
364
|
+
|
|
365
|
+
### "SSO connection failed"
|
|
366
|
+
|
|
367
|
+
- Pastikan `SSO_MAHULU_BASE_URL` benar dan server SSO bisa diakses.
|
|
368
|
+
- Check firewall/network connectivity.
|
|
369
|
+
|
|
370
|
+
### "Client authentication failed"
|
|
371
|
+
|
|
372
|
+
- Verify `CLIENT_ID` dan `CLIENT_SECRET` sudah benar.
|
|
373
|
+
- Check apakah client ter-revoke di admin panel SSO.
|
|
374
|
+
|
|
375
|
+
### CORS Error (jika frontend SPA)
|
|
376
|
+
|
|
377
|
+
- Pastikan domain aplikasi sudah ditambahkan di CORS config server SSO.
|
|
378
|
+
- Token exchange harus dilakukan di backend, bukan di browser.
|
|
379
|
+
|
|
380
|
+
## Struktur Package
|
|
381
|
+
|
|
382
|
+
```
|
|
383
|
+
sso-mahulu-node/
|
|
384
|
+
├── package.json
|
|
385
|
+
├── README.md
|
|
386
|
+
├── .gitignore
|
|
387
|
+
├── src/
|
|
388
|
+
│ ├── index.js # Main module (SsoMahuluClient + Express middleware)
|
|
389
|
+
│ └── index.d.ts # TypeScript type definitions
|
|
390
|
+
└── examples/
|
|
391
|
+
└── express-example.js # Contoh lengkap Express.js
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
## Lisensi
|
|
395
|
+
|
|
396
|
+
MIT License
|
|
397
|
+
|
|
398
|
+
## Support
|
|
399
|
+
|
|
400
|
+
- Email: support@mahulu.go.id
|
|
401
|
+
- Admin SSO: admin-sso@mahulu.go.id
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mahulu/sso-client",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Node.js SDK untuk integrasi OAuth 2.0 dengan SSO Mahulu",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"types": "src/index.d.ts",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"sso",
|
|
9
|
+
"oauth2",
|
|
10
|
+
"mahulu",
|
|
11
|
+
"authentication",
|
|
12
|
+
"nodejs",
|
|
13
|
+
"express"
|
|
14
|
+
],
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"author": {
|
|
17
|
+
"name": "Tysoft",
|
|
18
|
+
"email": "support@mahulu.go.id"
|
|
19
|
+
},
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=16.0.0"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"src/",
|
|
25
|
+
"README.md"
|
|
26
|
+
],
|
|
27
|
+
"scripts": {
|
|
28
|
+
"test": "echo \"No tests yet\""
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"express": ">=4.0.0",
|
|
32
|
+
"express-session": ">=1.17.0"
|
|
33
|
+
},
|
|
34
|
+
"peerDependenciesMeta": {
|
|
35
|
+
"express": {
|
|
36
|
+
"optional": true
|
|
37
|
+
},
|
|
38
|
+
"express-session": {
|
|
39
|
+
"optional": true
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"repository": {
|
|
43
|
+
"type": "git",
|
|
44
|
+
"url": "https://gitlab.com/nocash/sso-mahulu-nodejs-package.git"
|
|
45
|
+
},
|
|
46
|
+
"homepage": "https://gitlab.com/nocash/sso-mahulu-nodejs-package",
|
|
47
|
+
"bugs": {
|
|
48
|
+
"url": "https://gitlab.com/nocash/sso-mahulu-nodejs-package/issues"
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TypeScript type definitions untuk @mahulu/sso-client
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { NextFunction, Request, Response } from 'express';
|
|
6
|
+
|
|
7
|
+
/** Konfigurasi SSO Mahulu Client */
|
|
8
|
+
export interface SsoConfig {
|
|
9
|
+
/** URL server SSO Mahulu (contoh: https://sso.mahulu.go.id) */
|
|
10
|
+
baseUrl: string;
|
|
11
|
+
/** OAuth Client ID dari admin SSO */
|
|
12
|
+
clientId: string;
|
|
13
|
+
/** OAuth Client Secret dari admin SSO */
|
|
14
|
+
clientSecret: string;
|
|
15
|
+
/** Callback URL yang didaftarkan di admin SSO */
|
|
16
|
+
redirectUri: string;
|
|
17
|
+
/** OAuth scopes (opsional) */
|
|
18
|
+
scopes?: string;
|
|
19
|
+
/** HTTP timeout dalam milliseconds (default: 30000) */
|
|
20
|
+
timeout?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Response dari token exchange */
|
|
24
|
+
export interface TokenResponse {
|
|
25
|
+
token_type: 'Bearer';
|
|
26
|
+
expires_in: number;
|
|
27
|
+
access_token: string;
|
|
28
|
+
refresh_token: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Data user dari SSO */
|
|
32
|
+
export interface SsoUser {
|
|
33
|
+
id: number;
|
|
34
|
+
nip: string;
|
|
35
|
+
name: string;
|
|
36
|
+
email: string;
|
|
37
|
+
username: string;
|
|
38
|
+
is_active: boolean;
|
|
39
|
+
api_hub_data: {
|
|
40
|
+
jabatan?: string;
|
|
41
|
+
unit_kerja?: string;
|
|
42
|
+
pangkat_golongan?: string;
|
|
43
|
+
eselon?: string;
|
|
44
|
+
[key: string]: any;
|
|
45
|
+
} | null;
|
|
46
|
+
last_sync_api?: string;
|
|
47
|
+
email_verified_at?: string;
|
|
48
|
+
created_at: string;
|
|
49
|
+
updated_at: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Options tambahan untuk Express middleware */
|
|
53
|
+
export interface ExpressMiddlewareOptions {
|
|
54
|
+
/** Callback setelah login berhasil (untuk simpan user ke DB/session) */
|
|
55
|
+
onSuccess?: (req: Request, user: SsoUser, tokens: TokenResponse) => void | Promise<void>;
|
|
56
|
+
/** Callback saat error terjadi */
|
|
57
|
+
onError?: (req: Request, res: Response, error: Error) => void;
|
|
58
|
+
/** Path redirect setelah login sukses (default: /dashboard) */
|
|
59
|
+
redirectAfterLogin?: string;
|
|
60
|
+
/** Path redirect saat error (default: /login) */
|
|
61
|
+
redirectOnError?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** SSO Mahulu Client class */
|
|
65
|
+
export declare class SsoMahuluClient {
|
|
66
|
+
constructor(config: SsoConfig);
|
|
67
|
+
|
|
68
|
+
/** Generate random state string untuk CSRF protection */
|
|
69
|
+
generateState(length?: number): string;
|
|
70
|
+
|
|
71
|
+
/** Build authorization URL untuk redirect ke SSO login */
|
|
72
|
+
getAuthorizationUrl(state: string): string;
|
|
73
|
+
|
|
74
|
+
/** Exchange authorization code untuk access token */
|
|
75
|
+
exchangeCodeForToken(code: string): Promise<TokenResponse>;
|
|
76
|
+
|
|
77
|
+
/** Fetch user info dari SSO menggunakan access token */
|
|
78
|
+
getUserInfo(accessToken: string): Promise<SsoUser>;
|
|
79
|
+
|
|
80
|
+
/** Refresh expired access token */
|
|
81
|
+
refreshToken(refreshToken: string): Promise<TokenResponse>;
|
|
82
|
+
|
|
83
|
+
/** Build logout URL SSO */
|
|
84
|
+
getLogoutUrl(): string;
|
|
85
|
+
|
|
86
|
+
/** Check apakah token sudah expired */
|
|
87
|
+
isTokenExpired(expiresAt: Date | string | number): boolean;
|
|
88
|
+
|
|
89
|
+
/** Calculate expiry date dari expires_in (seconds) */
|
|
90
|
+
calculateExpiryDate(expiresIn: number): Date;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Express middleware handlers */
|
|
94
|
+
export interface SsoExpressHandlers {
|
|
95
|
+
/** Redirect ke SSO login page */
|
|
96
|
+
redirect: (req: Request, res: Response) => void;
|
|
97
|
+
/** Handle callback dari SSO */
|
|
98
|
+
callback: (req: Request, res: Response) => Promise<void>;
|
|
99
|
+
/** Logout dan redirect ke SSO logout */
|
|
100
|
+
logout: (req: Request, res: Response) => void;
|
|
101
|
+
/** Middleware: auto-refresh expired token */
|
|
102
|
+
ensureTokenValid: (req: Request, res: Response, next: NextFunction) => Promise<void>;
|
|
103
|
+
/** Underlying SSO client instance */
|
|
104
|
+
client: SsoMahuluClient;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Create Express middleware untuk SSO Mahulu */
|
|
108
|
+
export declare function createExpressMiddleware(
|
|
109
|
+
options: SsoConfig & ExpressMiddlewareOptions,
|
|
110
|
+
): SsoExpressHandlers;
|
|
111
|
+
|
|
112
|
+
/** Session augmentation */
|
|
113
|
+
declare module 'express-session' {
|
|
114
|
+
interface SessionData {
|
|
115
|
+
ssoState?: string;
|
|
116
|
+
ssoUser?: SsoUser;
|
|
117
|
+
ssoTokens?: {
|
|
118
|
+
accessToken: string;
|
|
119
|
+
refreshToken: string;
|
|
120
|
+
expiresAt: string;
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
const crypto = require("crypto");
|
|
2
|
+
const https = require("https");
|
|
3
|
+
const http = require("http");
|
|
4
|
+
const { URL, URLSearchParams } = require("url");
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* SSO Mahulu Client - OAuth 2.0 Authorization Code Flow
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* const { SsoMahuluClient } = require('@mahulu/sso-client');
|
|
11
|
+
*
|
|
12
|
+
* const sso = new SsoMahuluClient({
|
|
13
|
+
* baseUrl: 'https://sso.mahulu.go.id',
|
|
14
|
+
* clientId: 'your-client-id',
|
|
15
|
+
* clientSecret: 'your-client-secret',
|
|
16
|
+
* redirectUri: 'http://localhost:3000/auth/sso/callback',
|
|
17
|
+
* });
|
|
18
|
+
*/
|
|
19
|
+
class SsoMahuluClient {
|
|
20
|
+
/**
|
|
21
|
+
* @param {import('./index').SsoConfig} config
|
|
22
|
+
*/
|
|
23
|
+
constructor(config) {
|
|
24
|
+
if (!config.baseUrl) throw new Error("[SSO Mahulu] baseUrl is required");
|
|
25
|
+
if (!config.clientId) throw new Error("[SSO Mahulu] clientId is required");
|
|
26
|
+
if (!config.clientSecret)
|
|
27
|
+
throw new Error("[SSO Mahulu] clientSecret is required");
|
|
28
|
+
if (!config.redirectUri)
|
|
29
|
+
throw new Error("[SSO Mahulu] redirectUri is required");
|
|
30
|
+
|
|
31
|
+
this.baseUrl = config.baseUrl.replace(/\/+$/, "");
|
|
32
|
+
this.clientId = config.clientId;
|
|
33
|
+
this.clientSecret = config.clientSecret;
|
|
34
|
+
this.redirectUri = config.redirectUri;
|
|
35
|
+
this.scopes = config.scopes || "";
|
|
36
|
+
this.timeout = config.timeout || 30000;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Generate random state string untuk CSRF protection.
|
|
41
|
+
* @param {number} [length=32]
|
|
42
|
+
* @returns {string}
|
|
43
|
+
*/
|
|
44
|
+
generateState(length = 32) {
|
|
45
|
+
return crypto.randomBytes(length).toString("hex").slice(0, length);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Build authorization URL untuk redirect ke SSO login.
|
|
50
|
+
* @param {string} state - Random string untuk CSRF protection
|
|
51
|
+
* @returns {string}
|
|
52
|
+
*/
|
|
53
|
+
getAuthorizationUrl(state) {
|
|
54
|
+
const params = new URLSearchParams({
|
|
55
|
+
client_id: this.clientId,
|
|
56
|
+
redirect_uri: this.redirectUri,
|
|
57
|
+
response_type: "code",
|
|
58
|
+
scope: this.scopes,
|
|
59
|
+
state: state,
|
|
60
|
+
});
|
|
61
|
+
return `${this.baseUrl}/oauth/authorize?${params.toString()}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Exchange authorization code untuk access token.
|
|
66
|
+
* @param {string} code - Authorization code dari callback
|
|
67
|
+
* @returns {Promise<import('./index').TokenResponse>}
|
|
68
|
+
*/
|
|
69
|
+
async exchangeCodeForToken(code) {
|
|
70
|
+
const body = new URLSearchParams({
|
|
71
|
+
grant_type: "authorization_code",
|
|
72
|
+
client_id: this.clientId,
|
|
73
|
+
client_secret: this.clientSecret,
|
|
74
|
+
redirect_uri: this.redirectUri,
|
|
75
|
+
code: code,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const response = await this._request(
|
|
79
|
+
"POST",
|
|
80
|
+
"/oauth/token",
|
|
81
|
+
body.toString(),
|
|
82
|
+
{
|
|
83
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
84
|
+
},
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
return response;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Fetch user info dari SSO menggunakan access token.
|
|
92
|
+
* @param {string} accessToken
|
|
93
|
+
* @returns {Promise<import('./index').SsoUser>}
|
|
94
|
+
*/
|
|
95
|
+
async getUserInfo(accessToken) {
|
|
96
|
+
const response = await this._request("GET", "/api/oauth/user", null, {
|
|
97
|
+
Authorization: `Bearer ${accessToken}`,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
return response;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Refresh expired access token.
|
|
105
|
+
* @param {string} refreshToken
|
|
106
|
+
* @returns {Promise<import('./index').TokenResponse>}
|
|
107
|
+
*/
|
|
108
|
+
async refreshToken(refreshToken) {
|
|
109
|
+
const body = new URLSearchParams({
|
|
110
|
+
grant_type: "refresh_token",
|
|
111
|
+
client_id: this.clientId,
|
|
112
|
+
client_secret: this.clientSecret,
|
|
113
|
+
refresh_token: refreshToken,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const response = await this._request(
|
|
117
|
+
"POST",
|
|
118
|
+
"/oauth/token",
|
|
119
|
+
body.toString(),
|
|
120
|
+
{
|
|
121
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
122
|
+
},
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
return response;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Build logout URL SSO.
|
|
130
|
+
* @returns {string}
|
|
131
|
+
*/
|
|
132
|
+
getLogoutUrl() {
|
|
133
|
+
return `${this.baseUrl}/oauth/logout`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Check apakah token sudah expired.
|
|
138
|
+
* @param {Date|string|number} expiresAt - Waktu expiry token
|
|
139
|
+
* @returns {boolean}
|
|
140
|
+
*/
|
|
141
|
+
isTokenExpired(expiresAt) {
|
|
142
|
+
if (!expiresAt) return true;
|
|
143
|
+
const expiry = expiresAt instanceof Date ? expiresAt : new Date(expiresAt);
|
|
144
|
+
return Date.now() >= expiry.getTime();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Calculate expiry date dari expires_in (seconds).
|
|
149
|
+
* @param {number} expiresIn - Detik sampai expired
|
|
150
|
+
* @returns {Date}
|
|
151
|
+
*/
|
|
152
|
+
calculateExpiryDate(expiresIn) {
|
|
153
|
+
return new Date(Date.now() + expiresIn * 1000);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Internal HTTP request helper (zero dependencies).
|
|
158
|
+
* @private
|
|
159
|
+
*/
|
|
160
|
+
_request(method, path, body, headers) {
|
|
161
|
+
return new Promise((resolve, reject) => {
|
|
162
|
+
const url = new URL(path, this.baseUrl);
|
|
163
|
+
const isHttps = url.protocol === "https:";
|
|
164
|
+
const transport = isHttps ? https : http;
|
|
165
|
+
|
|
166
|
+
const options = {
|
|
167
|
+
hostname: url.hostname,
|
|
168
|
+
port: url.port || (isHttps ? 443 : 80),
|
|
169
|
+
path: url.pathname + url.search,
|
|
170
|
+
method: method,
|
|
171
|
+
headers: {
|
|
172
|
+
Accept: "application/json",
|
|
173
|
+
"User-Agent": "SSO-Mahulu-Node-Client/1.0",
|
|
174
|
+
...headers,
|
|
175
|
+
},
|
|
176
|
+
timeout: this.timeout,
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
if (body) {
|
|
180
|
+
options.headers["Content-Length"] = Buffer.byteLength(body);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const req = transport.request(options, (res) => {
|
|
184
|
+
let data = "";
|
|
185
|
+
res.on("data", (chunk) => {
|
|
186
|
+
data += chunk;
|
|
187
|
+
});
|
|
188
|
+
res.on("end", () => {
|
|
189
|
+
try {
|
|
190
|
+
const parsed = JSON.parse(data);
|
|
191
|
+
|
|
192
|
+
if (res.statusCode >= 400) {
|
|
193
|
+
const error = new Error(
|
|
194
|
+
parsed.error_description ||
|
|
195
|
+
parsed.message ||
|
|
196
|
+
`SSO request failed with status ${res.statusCode}`,
|
|
197
|
+
);
|
|
198
|
+
error.statusCode = res.statusCode;
|
|
199
|
+
error.response = parsed;
|
|
200
|
+
reject(error);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
resolve(parsed);
|
|
205
|
+
} catch (e) {
|
|
206
|
+
reject(
|
|
207
|
+
new Error(`Failed to parse SSO response: ${data.slice(0, 200)}`),
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
req.on("error", (err) => {
|
|
214
|
+
reject(new Error(`SSO connection failed: ${err.message}`));
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
req.on("timeout", () => {
|
|
218
|
+
req.destroy();
|
|
219
|
+
reject(new Error(`SSO request timed out after ${this.timeout}ms`));
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
if (body) {
|
|
223
|
+
req.write(body);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
req.end();
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Express middleware factory untuk SSO Mahulu.
|
|
233
|
+
*
|
|
234
|
+
* @param {import('./index').SsoConfig & import('./index').ExpressMiddlewareOptions} options
|
|
235
|
+
* @returns {{ redirect: Function, callback: Function, logout: Function, ensureTokenValid: Function }}
|
|
236
|
+
*
|
|
237
|
+
* @example
|
|
238
|
+
* const { createExpressMiddleware } = require('@mahulu/sso-client');
|
|
239
|
+
*
|
|
240
|
+
* const sso = createExpressMiddleware({
|
|
241
|
+
* baseUrl: process.env.SSO_MAHULU_BASE_URL,
|
|
242
|
+
* clientId: process.env.SSO_MAHULU_CLIENT_ID,
|
|
243
|
+
* clientSecret: process.env.SSO_MAHULU_CLIENT_SECRET,
|
|
244
|
+
* redirectUri: process.env.SSO_MAHULU_REDIRECT_URI,
|
|
245
|
+
* onSuccess: async (req, user, tokens) => {
|
|
246
|
+
* // Simpan user ke session atau database
|
|
247
|
+
* req.session.user = user;
|
|
248
|
+
* req.session.tokens = tokens;
|
|
249
|
+
* },
|
|
250
|
+
* });
|
|
251
|
+
*
|
|
252
|
+
* app.get('/auth/sso/redirect', sso.redirect);
|
|
253
|
+
* app.get('/auth/sso/callback', sso.callback);
|
|
254
|
+
* app.post('/auth/sso/logout', sso.logout);
|
|
255
|
+
*/
|
|
256
|
+
function createExpressMiddleware(options) {
|
|
257
|
+
const client = new SsoMahuluClient(options);
|
|
258
|
+
|
|
259
|
+
const onSuccess =
|
|
260
|
+
options.onSuccess ||
|
|
261
|
+
((req, user, tokens) => {
|
|
262
|
+
req.session.ssoUser = user;
|
|
263
|
+
req.session.ssoTokens = {
|
|
264
|
+
accessToken: tokens.access_token,
|
|
265
|
+
refreshToken: tokens.refresh_token,
|
|
266
|
+
expiresAt: client.calculateExpiryDate(tokens.expires_in).toISOString(),
|
|
267
|
+
};
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
const onError =
|
|
271
|
+
options.onError ||
|
|
272
|
+
((req, res, error) => {
|
|
273
|
+
console.error("[SSO Mahulu]", error.message);
|
|
274
|
+
res.redirect(options.redirectOnError || "/login?error=sso_failed");
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
const redirectAfterLogin = options.redirectAfterLogin || "/dashboard";
|
|
278
|
+
const redirectOnError = options.redirectOnError || "/login";
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
/**
|
|
282
|
+
* Redirect ke SSO login page.
|
|
283
|
+
*/
|
|
284
|
+
redirect(req, res) {
|
|
285
|
+
const state = client.generateState();
|
|
286
|
+
req.session.ssoState = state;
|
|
287
|
+
res.redirect(client.getAuthorizationUrl(state));
|
|
288
|
+
},
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Handle callback dari SSO.
|
|
292
|
+
*/
|
|
293
|
+
async callback(req, res) {
|
|
294
|
+
try {
|
|
295
|
+
// Validate state
|
|
296
|
+
const savedState = req.session.ssoState;
|
|
297
|
+
delete req.session.ssoState;
|
|
298
|
+
|
|
299
|
+
if (!savedState || savedState !== req.query.state) {
|
|
300
|
+
throw new Error(
|
|
301
|
+
"Invalid state parameter. Silakan coba login kembali.",
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Check for error from SSO in Query String
|
|
306
|
+
if (req.query.error) {
|
|
307
|
+
throw new Error(
|
|
308
|
+
req.query.error_description || "SSO authorization failed.",
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Check for authorization code in Query String
|
|
313
|
+
const code = req.query.code;
|
|
314
|
+
if (!code) {
|
|
315
|
+
throw new Error("Authorization code not found.");
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Exchange code for tokens
|
|
319
|
+
const tokens = await client.exchangeCodeForToken(code);
|
|
320
|
+
|
|
321
|
+
// Get user info
|
|
322
|
+
const user = await client.getUserInfo(tokens.access_token);
|
|
323
|
+
|
|
324
|
+
// Call onSuccess handler
|
|
325
|
+
await onSuccess(req, user, tokens);
|
|
326
|
+
|
|
327
|
+
// Save session then redirect
|
|
328
|
+
req.session.save((err) => {
|
|
329
|
+
if (err) console.error("[SSO Mahulu] Session save error:", err);
|
|
330
|
+
res.redirect(redirectAfterLogin);
|
|
331
|
+
});
|
|
332
|
+
} catch (error) {
|
|
333
|
+
onError(req, res, error);
|
|
334
|
+
}
|
|
335
|
+
},
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Logout dan redirect ke SSO logout.
|
|
339
|
+
*/
|
|
340
|
+
logout(req, res) {
|
|
341
|
+
// Clear SSO data from session
|
|
342
|
+
delete req.session.ssoUser;
|
|
343
|
+
delete req.session.ssoTokens;
|
|
344
|
+
|
|
345
|
+
req.session.destroy((err) => {
|
|
346
|
+
if (err) console.error("[SSO Mahulu] Session destroy error:", err);
|
|
347
|
+
res.redirect(client.getLogoutUrl());
|
|
348
|
+
});
|
|
349
|
+
},
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Middleware: pastikan SSO token masih valid, auto-refresh jika expired.
|
|
353
|
+
*/
|
|
354
|
+
async ensureTokenValid(req, res, next) {
|
|
355
|
+
if (!req.session || !req.session.ssoTokens) {
|
|
356
|
+
return next();
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const { expiresAt, refreshToken } = req.session.ssoTokens;
|
|
360
|
+
|
|
361
|
+
if (client.isTokenExpired(expiresAt)) {
|
|
362
|
+
if (!refreshToken) {
|
|
363
|
+
delete req.session.ssoUser;
|
|
364
|
+
delete req.session.ssoTokens;
|
|
365
|
+
return res.redirect(redirectOnError + "?error=session_expired");
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
try {
|
|
369
|
+
const newTokens = await client.refreshToken(refreshToken);
|
|
370
|
+
|
|
371
|
+
req.session.ssoTokens = {
|
|
372
|
+
accessToken: newTokens.access_token,
|
|
373
|
+
refreshToken: newTokens.refresh_token,
|
|
374
|
+
expiresAt: client
|
|
375
|
+
.calculateExpiryDate(newTokens.expires_in)
|
|
376
|
+
.toISOString(),
|
|
377
|
+
};
|
|
378
|
+
} catch (error) {
|
|
379
|
+
console.error("[SSO Mahulu] Token refresh failed:", error.message);
|
|
380
|
+
delete req.session.ssoUser;
|
|
381
|
+
delete req.session.ssoTokens;
|
|
382
|
+
return res.redirect(redirectOnError + "?error=session_expired");
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
next();
|
|
387
|
+
},
|
|
388
|
+
|
|
389
|
+
/** Expose the underlying client for advanced usage */
|
|
390
|
+
client,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
module.exports = {
|
|
395
|
+
SsoMahuluClient,
|
|
396
|
+
createExpressMiddleware,
|
|
397
|
+
};
|