@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 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
+ };