npm package: @mahulu/sso-client
Description: Node.js SDK untuk integrasi OAuth 2.0 dengan SSO Mahulu
Author: Tysoft
Latest version: 1.0.0

# SSO Mahulu - Node.js Client Package

> Node.js SDK untuk integrasi OAuth 2.0 dengan SSO Mahulu (Identity Provider Pemerintah Mahulu Hulu)

## Persyaratan

- Node.js >= 16.x
- OAuth Client Credentials dari admin SSO Mahulu

## Instalasi

```bash
npm install @mahulu/sso-client

# Jika menggunakan Express.js:
npm install express express-session
```

## Quick Start (Express.js)

### 1. Setup Express + SSO

```javascript
const express = require('express');
const session = require('express-session');
const { createExpressMiddleware } = require('@mahulu/sso-client');

const app = express();

// Setup session (wajib)
app.use(
    session({
        secret: 'your-random-secret-key',
        resave: false,
        saveUninitialized: false,
        cookie: { secure: process.env.NODE_ENV === 'production' },
    }),
);

// Setup SSO
const sso = createExpressMiddleware({
    baseUrl: process.env.SSO_MAHULU_BASE_URL, // https://sso.mahulu.go.id
    clientId: process.env.SSO_MAHULU_CLIENT_ID, // dari admin SSO
    clientSecret: process.env.SSO_MAHULU_CLIENT_SECRET, // dari admin SSO
    redirectUri: process.env.SSO_MAHULU_REDIRECT_URI, // http://localhost:3000/auth/sso/callback
    redirectAfterLogin: '/dashboard',
    redirectOnError: '/login',
});

// Register routes
app.get('/auth/sso/redirect', sso.redirect); // Redirect ke SSO
app.get('/auth/sso/callback', sso.callback); // Handle callback
app.post('/auth/sso/logout', sso.logout); // Logout
```

### 2. Environment Variables

Buat file `.env`:

```env
SSO_MAHULU_BASE_URL=https://sso.mahulu.go.id
SSO_MAHULU_CLIENT_ID=your-client-id-dari-admin
SSO_MAHULU_CLIENT_SECRET=your-client-secret-dari-admin
SSO_MAHULU_REDIRECT_URI=http://localhost:3000/auth/sso/callback
```

### 3. Tombol Login di Frontend

```html
<a href="/auth/sso/redirect">Login dengan SSO Mahulu</a>
```

### 4. Protected Routes

```javascript
// Middleware auth check
function requireAuth(req, res, next) {
    if (!req.session.ssoUser) {
        return res.redirect('/login');
    }
    next();
}

// Protected route + auto-refresh token
app.get('/dashboard', requireAuth, sso.ensureTokenValid, (req, res) => {
    const user = req.session.ssoUser;
    res.json({ message: `Selamat datang, ${user.name}!`, user });
});
```

### 5. Logout

```html
<form
    method="POST"
    action="/auth/sso/logout"
>
    <button type="submit">Logout</button>
</form>
```

## Penggunaan Lanjutan

### Custom onSuccess Handler

Gunakan `onSuccess` untuk menyimpan user ke database:

```javascript
const sso = createExpressMiddleware({
    // ... config
    onSuccess: async (req, user, tokens) => {
        // Simpan/update user di database
        const dbUser = await User.findOneAndUpdate(
            { nip: user.nip },
            {
                name: user.name,
                email: user.email,
                nip: user.nip,
                ssoAccessToken: tokens.access_token,
                ssoRefreshToken: tokens.refresh_token,
                tokenExpiresAt: new Date(Date.now() + tokens.expires_in * 1000),
            },
            { upsert: true, new: true },
        );

        // Simpan di session
        req.session.user = dbUser;
        req.session.ssoTokens = {
            accessToken: tokens.access_token,
            refreshToken: tokens.refresh_token,
            expiresAt: new Date(Date.now() + tokens.expires_in * 1000).toISOString(),
        };
    },
});
```

### Custom onError Handler

```javascript
const sso = createExpressMiddleware({
    // ... config
    onError: (req, res, error) => {
        console.error('[SSO Error]', error.message);

        // Render error page atau redirect
        res.status(400).render('login', {
            error: 'Login SSO gagal. Silakan coba lagi.',
        });
    },
});
```

### Menggunakan SsoMahuluClient Langsung (Tanpa Express)

Untuk framework lain (Fastify, Hapi, NestJS, dll):

```javascript
const { SsoMahuluClient } = require('@mahulu/sso-client');

const sso = new SsoMahuluClient({
    baseUrl: 'https://sso.mahulu.go.id',
    clientId: 'your-client-id',
    clientSecret: 'your-client-secret',
    redirectUri: 'http://localhost:3000/auth/sso/callback',
});

// 1. Generate state & redirect URL
const state = sso.generateState();
// Simpan state di session/store
const authUrl = sso.getAuthorizationUrl(state);
// Redirect user ke authUrl

// 2. Di callback handler
const tokens = await sso.exchangeCodeForToken(code);

// 3. Get user info
const user = await sso.getUserInfo(tokens.access_token);

// 4. Refresh token jika expired
if (sso.isTokenExpired(storedExpiresAt)) {
    const newTokens = await sso.refreshToken(storedRefreshToken);
}

// 5. Logout URL
const logoutUrl = sso.getLogoutUrl();
```

### Integrasi dengan NestJS

```typescript
// sso.module.ts
import { Module } from '@nestjs/common';
import { SsoMahuluClient } from '@mahulu/sso-client';

@Module({
    providers: [
        {
            provide: 'SSO_CLIENT',
            useFactory: () =>
                new SsoMahuluClient({
                    baseUrl: process.env.SSO_MAHULU_BASE_URL,
                    clientId: process.env.SSO_MAHULU_CLIENT_ID,
                    clientSecret: process.env.SSO_MAHULU_CLIENT_SECRET,
                    redirectUri: process.env.SSO_MAHULU_REDIRECT_URI,
                }),
        },
    ],
    exports: ['SSO_CLIENT'],
})
export class SsoModule {}

// sso.controller.ts
import { Controller, Get, Inject, Query, Req, Res, Session } from '@nestjs/common';
import { SsoMahuluClient } from '@mahulu/sso-client';

@Controller('auth/sso')
export class SsoController {
    constructor(@Inject('SSO_CLIENT') private sso: SsoMahuluClient) {}

    @Get('redirect')
    redirect(@Session() session: any, @Res() res: any) {
        const state = this.sso.generateState();
        session.ssoState = state;
        res.redirect(this.sso.getAuthorizationUrl(state));
    }

    @Get('callback')
    async callback(
        @Query('code') code: string,
        @Query('state') state: string,
        @Session() session: any,
        @Res() res: any,
    ) {
        if (state !== session.ssoState) {
            return res.redirect('/login?error=invalid_state');
        }

        const tokens = await this.sso.exchangeCodeForToken(code);
        const user = await this.sso.getUserInfo(tokens.access_token);

        session.user = user;
        session.tokens = tokens;

        res.redirect('/dashboard');
    }
}
```

### Integrasi dengan Next.js (API Routes)

```javascript
// pages/api/auth/sso/redirect.js
import { SsoMahuluClient } from '@mahulu/sso-client';

const sso = new SsoMahuluClient({
    baseUrl: process.env.SSO_MAHULU_BASE_URL,
    clientId: process.env.SSO_MAHULU_CLIENT_ID,
    clientSecret: process.env.SSO_MAHULU_CLIENT_SECRET,
    redirectUri: process.env.SSO_MAHULU_REDIRECT_URI,
});

export default function handler(req, res) {
    const state = sso.generateState();
    // Simpan state di cookie
    res.setHeader('Set-Cookie', `sso_state=${state}; Path=/; HttpOnly; SameSite=Lax`);
    res.redirect(302, sso.getAuthorizationUrl(state));
}

// pages/api/auth/sso/callback.js
export default async function handler(req, res) {
    const { code, state } = req.query;
    // Validasi state dari cookie...

    const tokens = await sso.exchangeCodeForToken(code);
    const user = await sso.getUserInfo(tokens.access_token);

    // Set JWT atau session cookie
    // ...

    res.redirect(302, '/dashboard');
}
```

## API Reference

### `SsoMahuluClient`

| Method                           | Parameter              | Return                   | Deskripsi                 |
| -------------------------------- | ---------------------- | ------------------------ | ------------------------- |
| `generateState(length?)`         | `number` (default: 32) | `string`                 | Generate random state     |
| `getAuthorizationUrl(state)`     | `string`               | `string`                 | Build authorization URL   |
| `exchangeCodeForToken(code)`     | `string`               | `Promise<TokenResponse>` | Exchange code untuk token |
| `getUserInfo(accessToken)`       | `string`               | `Promise<SsoUser>`       | Get user info             |
| `refreshToken(refreshToken)`     | `string`               | `Promise<TokenResponse>` | Refresh expired token     |
| `getLogoutUrl()`                 | -                      | `string`                 | Build SSO logout URL      |
| `isTokenExpired(expiresAt)`      | `Date\|string\|number` | `boolean`                | Check token expired       |
| `calculateExpiryDate(expiresIn)` | `number` (seconds)     | `Date`                   | Calculate expiry date     |

### `createExpressMiddleware(options)`

Returns object with handlers:

| Handler            | Tipe                     | Deskripsi                  |
| ------------------ | ------------------------ | -------------------------- |
| `redirect`         | `(req, res)`             | Redirect ke SSO login      |
| `callback`         | `async (req, res)`       | Handle callback SSO        |
| `logout`           | `(req, res)`             | Logout + redirect ke SSO   |
| `ensureTokenValid` | `async (req, res, next)` | Middleware auto-refresh    |
| `client`           | `SsoMahuluClient`        | Underlying client instance |

### User Data dari SSO

```typescript
interface SsoUser {
    id: number;
    nip: string; // "199001012020121001"
    name: string; // "Budi Santoso"
    email: string; // "budi.santoso@mahulu.go.id"
    username: string; // "199001012020121001"
    is_active: boolean; // true
    api_hub_data: {
        // Data tambahan dari API Hub
        jabatan?: string; // "Kepala Bidang Kepegawaian"
        unit_kerja?: string; // "Badan Kepegawaian Daerah"
        pangkat_golongan?: string;
        eselon?: string;
    } | null;
    created_at: string;
    updated_at: string;
}
```

### Token Expiration

| Token              | Durasi   |
| ------------------ | -------- |
| Access Token       | 60 menit |
| Refresh Token      | 14 hari  |
| Authorization Code | 10 menit |

## Fitur

- **Zero dependencies** - Hanya menggunakan built-in Node.js modules (`http`, `https`, `crypto`)
- **TypeScript support** - Type definitions included
- **Express middleware** - Plug-and-play untuk Express.js
- **Framework agnostic** - `SsoMahuluClient` class bisa digunakan dengan framework apapun
- **Auto token refresh** - Middleware `ensureTokenValid` otomatis refresh expired token
- **CSRF protection** - State parameter validation built-in

## Mendapatkan OAuth Client Credentials

1. Hubungi admin SSO Mahulu di `admin-sso@mahulu.go.id`
2. Sertakan: nama aplikasi, redirect URI, PIC
3. Terima `CLIENT_ID` & `CLIENT_SECRET`

**PENTING:** `CLIENT_SECRET` hanya ditampilkan SATU KALI. Simpan dengan aman.

## Troubleshooting

### "Invalid state parameter"

- Session mungkin expired. Pastikan `express-session` dikonfigurasi dengan benar.
- Pastikan `resave: false` dan `saveUninitialized: false`.

### "SSO connection failed"

- Pastikan `SSO_MAHULU_BASE_URL` benar dan server SSO bisa diakses.
- Check firewall/network connectivity.

### "Client authentication failed"

- Verify `CLIENT_ID` dan `CLIENT_SECRET` sudah benar.
- Check apakah client ter-revoke di admin panel SSO.

### CORS Error (jika frontend SPA)

- Pastikan domain aplikasi sudah ditambahkan di CORS config server SSO.
- Token exchange harus dilakukan di backend, bukan di browser.

## Struktur Package

```
sso-mahulu-node/
├── package.json
├── README.md
├── .gitignore
├── src/
│   ├── index.js          # Main module (SsoMahuluClient + Express middleware)
│   └── index.d.ts        # TypeScript type definitions
└── examples/
    └── express-example.js # Contoh lengkap Express.js
```

## Lisensi

MIT License

## Support

- Email: support@mahulu.go.id
- Admin SSO: admin-sso@mahulu.go.id