DEMO · Lingkungan uji coba, bukan layanan resmi

Panduan Integrasi SSO UNRI

Untuk pengembang aplikasi yang ingin login lewat SSO UNRI

Ringkasan

SSO UNRI memakai SAML 2.0. Aplikasi Anda tidak pernah menerima atau menyimpan password. Pengguna mengetik password di halaman SSO, lalu SSO mengirim bukti login bertanda tangan ke aplikasi. Aplikasi apa pun bisa terhubung selama bahasanya punya library SAML 2.0 Service Provider. Yang tidak punya bisa memakai mod_auth_mellon di depan aplikasinya.

  1. Pengguna membuka aplikasi dan menekan tombol masuk.
  2. Aplikasi mengalihkan browser ke SSO dengan AuthnRequest yang ditandatangani kunci aplikasi.
  3. Pengguna login di SSO. Bila sudah login sebelumnya, langkah ini dilewati.
  4. SSO mengirim SAML Response bertanda tangan lewat POST ke ACS aplikasi.
  5. Aplikasi memeriksa tanda tangan, lalu membuat sesinya sendiri.

Alamat SSO Asli

Alamat di bawah adalah alamat SSO ini, bukan contoh. Salin apa adanya.

Entity IDhttps://sso.dev-app.unri.ac.id/saml/metadata
Metadatahttps://sso.dev-app.unri.ac.id/saml/metadata
Single Sign-Onhttps://sso.dev-app.unri.ac.id/saml/sso, HTTP-Redirect
Single Logouthttps://sso.dev-app.unri.ac.id/saml/slo, HTTP-Redirect
Respons ke aplikasiHTTP-POST ke ACS yang terdaftar
Tanda tanganRSA-SHA256, wajib di AuthnRequest dan pesan logout dari aplikasi
NameIDurn:oasis:names:tc:SAML:2.0:nameid-format:persistent
Masa berlakuAssertion 5 menit, toleransi jam 120 detik

Sertifikat SSO

Sertifikat yang sama ada di metadata. Simpan sebagai idp.crt di server aplikasi.

-----BEGIN CERTIFICATE-----
MIIEdzCCAt+gAwIBAgIIMznLIWgqGJQwDQYJKoZIhvcNAQELBQAwUTESMBAGA1UE
AwwJMTI3LjAuMC4xMRkwFwYDVQQKDBBVbml2ZXJzaXRhcyBSaWF1MQswCQYDVQQG
EwJJRDETMBEGA1UECAwKU29tZS1TdGF0ZTAeFw0yNjExMDIwOTQxNTdaFw0zNjEw
MzAwOTQxNTdaMFExEjAQBgNVBAMMCTEyNy4wLjAuMTEZMBcGA1UECgwQVW5pdmVy
c2l0YXMgUmlhdTELMAkGA1UEBhMCSUQxEzARBgNVBAgMClNvbWUtU3RhdGUwggGi
MA0GCSqGSIb3DQEBAQUAA4IBjwAwggGKAoIBgQCS0dP6yhMOGqphaMT0oJqYOod4
7WV1qtjsZOWeCPSIy+QKtJs1I3jV5yMtH6WMneFAwI3kqOkKmZB+ISkfUUGefMh5
Lytbw5TuRdyGLb/UTuMpe7Kz8B6/9/xQzjrylDgO/X2wMtxsURRCP9eftpVbkMMd
IOrEZN2tq1n+/GsGzAIt89MAXGnZKfOMkNnaDyjhnwjeNyjOzLb3p3teYqZxoYci
Ep7P6W9tLzwIUtlcK/Y1aQTNHPBIG8pV9/94Zzwa8DMc9ZiumwjgdsOpFNeV8py7
7xuyjJyS63JSJtwxqRhte1TFAjeUlIUoDYeTe8FAS0m1SC7lUHVJ4dAUIxmBJt1D
KRe2S3eohGhCAucUj4bDHKyPViXrHWF3SEP/VhDvYWf34bMLEGAC/sdX8Mer50Yt
5IeZrVRJW+50alHVEQd2c9ckuZKFY5M44H6wMrpyR99trXUfPH5cTx+KZmOyBPws
1AXsXqMK/OwaYCK6Vw5r0uj1De+CV6J+0McoxsMCAwEAAaNTMFEwHQYDVR0OBBYE
FHkeAbYQaVgD7A3AhmE5L+7iUtRQMB8GA1UdIwQYMBaAFHkeAbYQaVgD7A3AhmE5
L+7iUtRQMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggGBAGc/B1af
WBCP/XSAXn5wpWgeBJVguvDWXCcXqOUZqGNStzVP9BVpzReJflYRhpAgFn8rVX5H
1xECN6CqCgOKV9ZwcDbbfJsF1+bYQ90u/M3HgQjSN2PxbQAd0vye0TH00TsTV80S
yKuX1QKvUViWUNezraVRkxZZKdMKA0tVRjd4HDTm3VjKfMOv6zUJm2m2eBnXEmsS
CZREFcCvbGzqo4cG30ekEjlt3/aANCSRl8L64GzHKV2c8UEo0AihwgnWGiMwqkxO
5or3z8b5y04HF5l/xxTxcQlNlRuj4qM3S+a4do7XANN0K8oTAoUd3+6N/kjQ4LWu
29qRFnVSoyBE+NiRi1T8NdL/eXgR/GoQnzjC1qHaL/V8KAKUB+fTnJTiCJs5UC+x
T54lKn/e8wY0WI/lX93jCVFe1yaIIx2bzAbkoofcf55OUezxv2EtfOddLf2nLDrv
iqeSOD1uKi7lQ4bksT7DCh9eGgBME4Zq+SiJU2pmxyOqUsFxlcRrZDHgbA==
-----END CERTIFICATE-----

Data pengguna yang diterima

NamaIsi
NameIDpegawai:<id> atau mahasiswa:<NIM>. Identitas tetap; pakai ini sebagai kunci pengguna.
account_typepegawai atau mahasiswa
nameNama lengkap
registration_numberNIP atau NIM. Bisa tidak ada untuk pegawai tanpa NIP.
emailEmail pegawai atau email kampus mahasiswa. Bisa tidak ada.

AuthnContextClassRef berisi urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken bila pengguna memakai kode authenticator, dan PasswordProtectedTransport bila hanya password. Aplikasi yang punya 2FA sendiri bisa melewatinya bila nilainya TimeSyncToken.

Aplikasi tidak menerima password. Bila perlu tabel pengguna lokal, simpan NameID dan atribut di atas tanpa kolom password.

Langkah integrasi

  1. Buat kunci aplikasi

    openssl req -x509 -newkey rsa:3072 -nodes -days 3650 -subj "/CN=nama-aplikasi" \
      -keyout sp.key -out sp.crt
    chmod 600 sp.key

    nama-aplikasi adalah contoh; isi dengan nama aplikasi Anda, misalnya portal-wifi. sp.key tetap di server aplikasi dan jangan masuk ke git. sp.crt diserahkan ke pengelola SSO.

  2. Ambil sertifikat SSO

    Salin sertifikat di bagian Alamat SSO ke idp.crt, atau arahkan library ke URL metadata.

  3. Pasang library dan route

    Pilih bahasa di Contoh kode. Ganti https://app.unri.ac.id dengan alamat aplikasi Anda.

  4. Minta pendaftaran ke pengelola SSO

    SSO menolak aplikasi yang belum terdaftar. Kirim data berikut:

    DataKeteranganContoh
    Nama dan deskripsiTampil di portal SSOPortal Wifi, Akses internet kampus
    Entity IDBiasanya URL metadata aplikasihttps://app.unri.ac.id/saml/metadata
    ACS URLAlamat yang menerima POST dari SSOhttps://app.unri.ac.id/saml/acs
    SLO URLAlamat logout. Boleh kosong bila tidak mendukung Single Logouthttps://app.unri.ac.id/saml/slo atau kosong
    Login URLHalaman yang memulai login, dipakai kartu di portal SSO. Boleh kosong bila aplikasi tidak perlu tampil di portalhttps://app.unri.ac.id/saml/login atau kosong
    Jenis akunPegawai, mahasiswa, atau keduanyaPegawai dan mahasiswa
    SertifikatIsi sp.crtBerkas yang diawali -----BEGIN CERTIFICATE-----

    Kolom Contoh hanya ilustrasi. Alamat yang benar mengikuti alamat aplikasi Anda dan route bawaan library; lihat tabel di bawah tiap contoh kode.

  5. Uji

    Login, logout dari aplikasi, lalu buka SSO: SSO harus meminta login lagi. Coba juga akun dengan jenis yang tidak diizinkan; SSO harus menolaknya.

Prompt untuk asisten AI

Salin teks di bawah ke asisten AI pilihan Anda, misalnya ChatGPT, Claude, Gemini, atau Copilot. Isi dulu bagian [dalam kurung siku] dengan data aplikasi Anda. Alamat SSO di dalamnya sudah alamat asli server ini.

Bantu saya mengintegrasikan aplikasi saya ke SSO UNRI, sebuah SAML 2.0 Identity Provider. Ikuti spesifikasi di bawah dengan tepat. Jangan menebak nilai yang tidak tercantum; tanyakan dulu ke saya.

== TENTANG APLIKASI SAYA (isi dulu bagian dalam kurung siku) ==
- Bahasa dan framework: [CONTOH: Laravel 11 / Django 5 / Express / Spring Boot 3 / ASP.NET 8 / Go / Rails]
- Alamat publik aplikasi, HTTPS: [CONTOH: https://app.unri.ac.id]
- Jenis akun yang boleh masuk: [pegawai / mahasiswa / keduanya]
- Cara aplikasi menyimpan pengguna saat ini: [CONTOH: tabel users dengan kolom email]
- Berjalan di balik reverse proxy: [ya / tidak]

== IDENTITY PROVIDER (nilai asli, jangan diubah) ==
- Entity ID: https://sso.dev-app.unri.ac.id/saml/metadata
- Metadata: https://sso.dev-app.unri.ac.id/saml/metadata
- Single Sign-On: https://sso.dev-app.unri.ac.id/saml/sso (hanya HTTP-Redirect)
- Single Logout: https://sso.dev-app.unri.ac.id/saml/slo (hanya HTTP-Redirect)
- Sertifikat penandatangan IdP ada di metadata (KeyDescriptor use="signing"). Simpan sebagai idp.crt.
- Hanya alur SP-initiated. IdP-initiated tidak didukung.
- Panduan lengkap dan contoh kode: https://sso.dev-app.unri.ac.id/panduan

== ATURAN PROTOKOL ==
1. AuthnRequest dikirim lewat HTTP-Redirect dan WAJIB ditandatangani RSA-SHA256 dengan kunci privat SP (parameter SigAlg dan Signature di query string). AuthnRequest lewat HTTP-POST ditolak.
2. Issuer AuthnRequest harus sama persis dengan Entity ID SP yang didaftarkan. Bila AssertionConsumerServiceURL diisi, nilainya harus sama persis dengan ACS URL terdaftar. Bila ProtocolBinding diisi, nilainya harus HTTP-POST.
3. SAML Response datang lewat HTTP-POST ke ACS. Response dan Assertion ditandatangani RSA-SHA256. Validasi tanda tangan, Issuer, Audience (= Entity ID SP), Destination dan Recipient (= ACS URL), InResponseTo (= ID AuthnRequest yang disimpan), serta NotBefore dan NotOnOrAfter. Assertion berlaku 300 detik dengan toleransi jam 120 detik.
4. Pakai mode strict. Jangan mematikan validasi apa pun, termasuk saat uji coba.
5. RelayState maksimal 1024 byte.
6. Jangan mengirim RequestedAuthnContext.

== DATA PENGGUNA ==
- NameID, format persistent: "pegawai:<id>" atau "mahasiswa:<NIM>". Jadikan ini kunci pengguna. JANGAN memakai email sebagai identitas, karena mahasiswa bisa mengubah email sendiri dan sebagian email dipakai bersama.
- Atribut, nama tanpa awalan URI, masing-masing satu nilai:
  account_type = pegawai | mahasiswa
  name = nama lengkap
  email = bisa tidak ada
  registration_number = NIP atau NIM, bisa tidak ada
- AuthnContextClassRef bernilai urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken bila pengguna memakai kode authenticator, selain itu PasswordProtectedTransport. Baca dari elemen XML, bukan dari isi atribut.
- SSO tidak pernah mengirim password. Jangan membuat kolom password untuk pengguna SSO.
- Bila aplikasi butuh tabel pengguna lokal: cari atau buat berdasarkan NameID, lalu perbarui name dan email dari atribut setiap kali login.

== LOGOUT ==
- Bawaan: logout di aplikasi hanya menghapus sesi lokal. Jangan mengirim LogoutRequest ke SSO dan jangan membuat endpoint SLO, kecuali saya meminta Single Logout.
- Bila saya meminta Single Logout: endpoint SLO menerima LogoutRequest bertanda tangan dari SSO lewat HTTP-Redirect, mengakhiri sesi lokal, lalu membalas LogoutResponse bertanda tangan RSA-SHA256 lewat HTTP-Redirect.

== KEAMANAN APLIKASI ==
- Kecualikan ACS dari proteksi CSRF.
- Buat ulang session ID setelah login berhasil.
- Pakai HTTPS. Simpan sp.key di luar git dengan izin baca hanya untuk aplikasi.
- Bila aplikasi dan SSO beda situs (tidak sama-sama di *.unri.ac.id), cookie sesi SameSite=Lax tidak ikut pada POST ke ACS. Simpan ID AuthnRequest di cookie terpisah SameSite=None; Secure, atau di cache server yang dikunci oleh cookie itu, supaya InResponseTo tetap bisa diperiksa.
- Di balik reverse proxy, bentuk URL dari alamat publik yang dikonfigurasi, bukan dari header request.
- Catat alasan kegagalan validasi di log server. Ke pengguna cukup tampilkan pesan umum.

== YANG SAYA MINTA ==
1. Pilih library SAML 2.0 Service Provider yang masih terawat untuk stack saya, sebutkan versi minimalnya, dan jelaskan alasannya.
2. Perintah membuat kunci SP:
   openssl req -x509 -newkey rsa:3072 -nodes -days 3650 -subj "/CN=<nama-aplikasi>" -keyout sp.key -out sp.crt
3. Kode lengkap: konfigurasi, route mulai login, ACS, metadata SP, logout lokal, dan pemetaan pengguna dari NameID.
4. Daftar nilai yang harus saya kirim ke pengelola SSO: Entity ID, ACS URL, SLO URL (kosong bila tanpa Single Logout), Login URL (boleh kosong), jenis akun, dan isi sp.crt.
5. Langkah uji: login berhasil; login ulang tanpa password selama sesi SSO masih aktif; akun dengan jenis yang tidak diizinkan ditolak SSO; respons yang dikirim ulang ditolak aplikasi.
Tandai dengan jelas setiap nilai contoh yang harus saya ganti.

Periksa hasil dari AI dengan Aturan wajib dan Contoh kode sebelum dipakai. AI bisa keliru menyebut nama fungsi atau opsi library.

Contoh kode

Setiap contoh lengkap: memulai login, menerima respons, logout, menerima logout dari SSO, dan metadata.

Bedakan alamat contoh dan alamat asli.

  • Contoh https://app.unri.ac.id, 127.0.0.1:8000, dan lokasi file seperti saml/sp.key atau /etc/app/saml hanya contoh. Ganti dengan alamat dan lokasi milik aplikasi Anda.
  • Asli https://sso.dev-app.unri.ac.id adalah alamat SSO ini. Pakai apa adanya.

Di dalam kode, alamat contoh ditandai komentar CONTOH.

PHP

onelogin/php-saml 4.3.2 ke atas

<?php
// composer require onelogin/php-saml:^4.3.2

use OneLogin\Saml2\Auth;

require __DIR__.'/vendor/autoload.php';

const APP = 'https://app.unri.ac.id'; // CONTOH: ganti dengan alamat aplikasi Anda
const SSO = 'https://sso.dev-app.unri.ac.id';             // alamat SSO ini, jangan diubah
const PERSISTENT = 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent';

session_start();

$auth = new Auth([
    'strict' => true,
    'sp' => [
        'entityId' => APP.'/saml/metadata',
        'assertionConsumerService' => ['url' => APP.'/saml/acs'],
        'singleLogoutService' => ['url' => APP.'/saml/slo'],
        'NameIDFormat' => PERSISTENT,
        'x509cert' => file_get_contents(__DIR__.'/saml/sp.crt'),
        'privateKey' => file_get_contents(__DIR__.'/saml/sp.key'),
    ],
    'idp' => [
        'entityId' => SSO.'/saml/metadata',
        'singleSignOnService' => ['url' => SSO.'/saml/sso'],
        'singleLogoutService' => ['url' => SSO.'/saml/slo'],
        'x509cert' => file_get_contents(__DIR__.'/saml/idp.crt'),
    ],
    'security' => [
        'authnRequestsSigned' => true,
        'logoutRequestSigned' => true,
        'logoutResponseSigned' => true,
        'wantAssertionsSigned' => true,
        'wantMessagesSigned' => true,
        'requestedAuthnContext' => false,
        'rejectUnsolicitedResponsesWithInResponseTo' => true,
        'signatureAlgorithm' => 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256',
        'digestAlgorithm' => 'http://www.w3.org/2001/04/xmlenc#sha256',
    ],
]);

switch (parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH)) {
    case '/saml/login':
        $url = $auth->login(APP.'/', [], false, false, true);
        $_SESSION['saml_request_id'] = $auth->getLastRequestID();
        header('Location: '.$url);
        exit;

    case '/saml/acs':
        $auth->processResponse($_SESSION['saml_request_id'] ?? null);
        unset($_SESSION['saml_request_id']);

        if ($auth->getErrors() || ! $auth->isAuthenticated()) {
            error_log('Login SSO gagal: '.$auth->getLastErrorReason());
            http_response_code(403);
            exit('Login SSO gagal.');
        }

        session_regenerate_id(true);
        $_SESSION['user'] = [
            'id' => $auth->getNameId(),                       // pegawai:123 atau mahasiswa:2107110001
            'type' => $auth->getAttribute('account_type')[0], // pegawai atau mahasiswa
            'name' => $auth->getAttribute('name')[0] ?? null,
            'email' => $auth->getAttribute('email')[0] ?? null,
        ];
        $_SESSION['saml'] = ['name_id' => $auth->getNameId(), 'session_index' => $auth->getSessionIndex()];
        header('Location: /');
        exit;

    case '/saml/logout':
        $saml = $_SESSION['saml'] ?? [];
        $url = $auth->logout(APP.'/', [], $saml['name_id'] ?? null, $saml['session_index'] ?? null, true, PERSISTENT);
        $_SESSION['saml_logout_id'] = $auth->getLastRequestID();
        header('Location: '.$url);
        exit;

    case '/saml/slo':
        // Menangani dua hal: SSO meminta logout, atau SSO menjawab logout dari aplikasi ini.
        $url = $auth->processSLO(false, $_SESSION['saml_logout_id'] ?? null, false, fn () => session_destroy(), true);
        header('Location: '.($url ?: '/'));
        exit;

    case '/saml/metadata':
        header('Content-Type: application/xml');
        echo $auth->getSettings()->getSPMetadata();
        exit;
}

Alamat yang didaftarkan Contoh

Ganti https://app.unri.ac.id dengan alamat aplikasi Anda. Path di belakangnya adalah bawaan library ini.

Entity IDhttps://app.unri.ac.id/saml/metadata
ACS URLhttps://app.unri.ac.id/saml/acs
SLO URLhttps://app.unri.ac.id/saml/slo
Login URLhttps://app.unri.ac.id/saml/login
  • Versi 4.3.1 ke bawah bermasalah bila terpasang bersama xmlseclibs 4.
  • Di Laravel, pindahkan tiap case ke route dan kecualikan saml/acs dari CSRF.

Python

python3-saml (contoh dengan Flask)

# pip install python3-saml flask
import os
from urllib.parse import urlparse

from flask import Flask, abort, make_response, redirect, request, session
from onelogin.saml2.auth import OneLogin_Saml2_Auth

APP = "https://app.unri.ac.id"  # CONTOH: ganti dengan alamat aplikasi Anda
SSO = "https://sso.dev-app.unri.ac.id"  # alamat SSO ini, jangan diubah
PERSISTENT = "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent"


def read(path):
    with open(path) as f:
        return f.read()


SETTINGS = {
    "strict": True,
    "sp": {
        "entityId": f"{APP}/saml/metadata",
        "assertionConsumerService": {"url": f"{APP}/saml/acs"},
        "singleLogoutService": {"url": f"{APP}/saml/slo"},
        "NameIDFormat": PERSISTENT,
        "x509cert": read("saml/sp.crt"),
        "privateKey": read("saml/sp.key"),
    },
    "idp": {
        "entityId": f"{SSO}/saml/metadata",
        "singleSignOnService": {"url": f"{SSO}/saml/sso"},
        "singleLogoutService": {"url": f"{SSO}/saml/slo"},
        "x509cert": read("saml/idp.crt"),
    },
    "security": {
        "authnRequestsSigned": True,
        "logoutRequestSigned": True,
        "logoutResponseSigned": True,
        "wantAssertionsSigned": True,
        "wantMessagesSigned": True,
        "requestedAuthnContext": False,
        "rejectUnsolicitedResponsesWithInResponseTo": True,
        "signatureAlgorithm": "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
        "digestAlgorithm": "http://www.w3.org/2001/04/xmlenc#sha256",
    },
}

app = Flask(__name__)
app.secret_key = os.environ["SECRET_KEY"]


def saml_auth():
    # Host diambil dari APP, bukan dari request, supaya tetap benar di balik proxy.
    return OneLogin_Saml2_Auth({
        "https": "on",
        "http_host": urlparse(APP).netloc,
        "script_name": request.path,
        "get_data": request.args.copy(),
        "post_data": request.form.copy(),
    }, SETTINGS)


@app.get("/saml/login")
def login():
    auth = saml_auth()
    url = auth.login(return_to=f"{APP}/")
    session["saml_request_id"] = auth.get_last_request_id()
    return redirect(url)


@app.post("/saml/acs")
def acs():
    auth = saml_auth()
    auth.process_response(request_id=session.pop("saml_request_id", None))

    if auth.get_errors() or not auth.is_authenticated():
        app.logger.warning("Login SSO gagal: %s", auth.get_last_error_reason())
        abort(403)

    attrs = auth.get_attributes()
    session.clear()
    session["user"] = {
        "id": auth.get_nameid(),           # pegawai:123 atau mahasiswa:2107110001
        "type": attrs["account_type"][0],  # pegawai atau mahasiswa
        "name": attrs.get("name", [None])[0],
        "email": attrs.get("email", [None])[0],
    }
    session["saml"] = {"name_id": auth.get_nameid(), "session_index": auth.get_session_index()}
    return redirect("/")


@app.get("/saml/logout")
def logout():
    auth = saml_auth()
    saml = session.get("saml", {})
    url = auth.logout(
        return_to=f"{APP}/",
        name_id=saml.get("name_id"),
        session_index=saml.get("session_index"),
        name_id_format=PERSISTENT,
    )
    session["saml_logout_id"] = auth.get_last_request_id()
    return redirect(url)


# Menangani dua hal: SSO meminta logout, atau SSO menjawab logout dari aplikasi ini.
@app.get("/saml/slo")
def slo():
    auth = saml_auth()
    url = auth.process_slo(request_id=session.get("saml_logout_id"), delete_session_cb=session.clear)
    if auth.get_errors():
        abort(400)
    return redirect(url or "/")


@app.get("/saml/metadata")
def metadata():
    response = make_response(saml_auth().get_settings().get_sp_metadata())
    response.headers["Content-Type"] = "application/xml"
    return response

Alamat yang didaftarkan Contoh

Ganti https://app.unri.ac.id dengan alamat aplikasi Anda. Path di belakangnya adalah bawaan library ini.

Entity IDhttps://app.unri.ac.id/saml/metadata
ACS URLhttps://app.unri.ac.id/saml/acs
SLO URLhttps://app.unri.ac.id/saml/slo
Login URLhttps://app.unri.ac.id/saml/login
  • Butuh pustaka sistem xmlsec: apt install libxmlsec1-dev pkg-config.
  • Untuk Django, pengaturannya sama; bedanya hanya cara membaca request dan sesi.

Node.js

@node-saml/node-saml 5 (contoh dengan Express)

// npm i express express-session @node-saml/node-saml
import fs from "node:fs";
import express from "express";
import session from "express-session";
import { SAML, ValidateInResponseTo } from "@node-saml/node-saml";

const APP = "https://app.unri.ac.id"; // CONTOH: ganti dengan alamat aplikasi Anda
const SSO = "https://sso.dev-app.unri.ac.id"; // alamat SSO ini, jangan diubah
const spCert = fs.readFileSync("saml/sp.crt", "utf8");

const saml = new SAML({
  issuer: `${APP}/saml/metadata`,
  audience: `${APP}/saml/metadata`,
  callbackUrl: `${APP}/saml/acs`,
  logoutCallbackUrl: `${APP}/saml/slo`,
  entryPoint: `${SSO}/saml/sso`,
  logoutUrl: `${SSO}/saml/slo`,
  idpIssuer: `${SSO}/saml/metadata`,
  idpCert: fs.readFileSync("saml/idp.crt", "utf8"),
  privateKey: fs.readFileSync("saml/sp.key", "utf8"),
  publicCert: spCert,
  identifierFormat: "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent",
  signatureAlgorithm: "sha256",
  digestAlgorithm: "sha256",
  wantAssertionsSigned: true,
  wantAuthnResponseSigned: true,
  validateInResponseTo: ValidateInResponseTo.always,
  disableRequestedAuthnContext: true,
});

const app = express();
app.set("trust proxy", 1);
app.use(express.urlencoded({ extended: false }));
app.use(session({
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: { secure: true, httpOnly: true },
}));

app.get("/saml/login", async (req, res, next) => {
  try {
    res.redirect(await saml.getAuthorizeUrlAsync("", undefined, {}));
  } catch (err) {
    next(err);
  }
});

app.post("/saml/acs", async (req, res) => {
  try {
    const { profile } = await saml.validatePostResponseAsync(req.body);
    req.session.regenerate(() => {
      req.session.user = {
        id: profile.nameID,                    // pegawai:123 atau mahasiswa:2107110001
        type: profile.attributes.account_type, // pegawai atau mahasiswa
        name: profile.attributes.name,
        email: profile.attributes.email,
      };
      req.session.saml = {
        issuer: profile.issuer,
        nameID: profile.nameID,
        nameIDFormat: profile.nameIDFormat,
        sessionIndex: profile.sessionIndex,
      };
      res.redirect("/");
    });
  } catch (err) {
    console.warn("Login SSO gagal:", err.message);
    res.status(403).send("Login SSO gagal.");
  }
});

app.get("/saml/logout", async (req, res, next) => {
  try {
    if (!req.session.saml) return req.session.destroy(() => res.redirect("/"));
    res.redirect(await saml.getLogoutUrlAsync(req.session.saml, "", {}));
  } catch (err) {
    next(err);
  }
});

// Menangani dua hal: SSO meminta logout, atau SSO menjawab logout dari aplikasi ini.
app.get("/saml/slo", async (req, res) => {
  try {
    const originalQuery = req.originalUrl.split("?")[1] ?? "";
    const { profile } = await saml.validateRedirectAsync(req.query, originalQuery);

    if (req.query.SAMLRequest) {
      const url = await saml.getLogoutResponseUrlAsync(profile, req.query.RelayState ?? "", {}, true);
      return req.session.destroy(() => res.redirect(url));
    }

    req.session.destroy(() => res.redirect("/"));
  } catch (err) {
    console.warn("Logout SSO gagal:", err.message);
    res.status(400).send("Logout SSO gagal.");
  }
});

app.get("/saml/metadata", (req, res) => {
  res.type("application/xml").send(saml.generateServiceProviderMetadata(null, spCert));
});

app.listen(3000);

Alamat yang didaftarkan Contoh

Ganti https://app.unri.ac.id dengan alamat aplikasi Anda. Path di belakangnya adalah bawaan library ini.

Entity IDhttps://app.unri.ac.id/saml/metadata
ACS URLhttps://app.unri.ac.id/saml/acs
SLO URLhttps://app.unri.ac.id/saml/slo
Login URLhttps://app.unri.ac.id/saml/login
  • ID permintaan login disimpan di memori proses. Bila aplikasi berjalan lebih dari satu instance, isi opsi cacheProvider dengan Redis.
  • Untuk Passport, @node-saml/passport-saml menerima opsi yang sama.

Java

Spring Security SAML2 Service Provider 6.1 ke atas

// Spring Boot 3 + Spring Security 6.1 ke atas.
// Dependensi: spring-boot-starter-security dan spring-security-saml2-service-provider.
// OpenSAML diambil dari repositori Shibboleth: https://build.shibboleth.net/maven/releases/

# ---- src/main/resources/application.yml ----
spring:
  security:
    saml2:
      relyingparty:
        registration:
          unri:
            # CONTOH: ganti app.unri.ac.id dengan alamat aplikasi Anda
            entity-id: "https://app.unri.ac.id/saml2/metadata/unri"
            signing:
              credentials:
                # CONTOH: lokasi kunci di server aplikasi Anda
                - private-key-location: "file:/etc/app/saml/sp.key"
                  certificate-location: "file:/etc/app/saml/sp.crt"
            singlelogout:
              url: "{baseUrl}/logout/saml2/slo"
              binding: REDIRECT
            assertingparty:
              metadata-uri: "https://sso.dev-app.unri.ac.id/saml/metadata" # alamat SSO ini, jangan diubah

// ---- SecurityConfig.java ----
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    SecurityFilterChain security(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
            .saml2Login(Customizer.withDefaults())
            .saml2Logout(Customizer.withDefaults())
            .saml2Metadata(Customizer.withDefaults());

        return http.build();
    }
}

// ---- HomeController.java ----
@RestController
public class HomeController {

    @GetMapping("/")
    public Map<String, Object> home(@AuthenticationPrincipal Saml2AuthenticatedPrincipal user) {
        return Map.of(
            "id", user.getName(),                           // pegawai:123 atau mahasiswa:2107110001
            "type", user.getFirstAttribute("account_type"), // pegawai atau mahasiswa
            "name", Objects.toString(user.getFirstAttribute("name"), ""),
            "email", Objects.toString(user.getFirstAttribute("email"), "")
        );
    }
}

Alamat yang didaftarkan Contoh

Ganti https://app.unri.ac.id dengan alamat aplikasi Anda. Path di belakangnya adalah bawaan library ini.

Entity IDhttps://app.unri.ac.id/saml2/metadata/unri
ACS URLhttps://app.unri.ac.id/login/saml2/sso/unri
SLO URLhttps://app.unri.ac.id/logout/saml2/slo
Login URLhttps://app.unri.ac.id/saml2/authenticate/unri
  • Kunci SP harus PKCS#8 (BEGIN PRIVATE KEY). Perintah openssl di langkah 1 sudah menghasilkan format ini.
  • Logout dengan POST /logout; Spring meneruskannya ke SSO.

.NET

Sustainsys.Saml2.AspNetCore2

// dotnet add package Sustainsys.Saml2.AspNetCore2
// Kunci SP diubah ke PFX dulu:
//   openssl pkcs12 -export -in sp.crt -inkey sp.key -out sp.pfx

using System.Security.Claims;
using System.Security.Cryptography.X509Certificates;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Sustainsys.Saml2;
using Sustainsys.Saml2.AspNetCore2;
using Sustainsys.Saml2.Metadata;

const string App = "https://app.unri.ac.id"; // CONTOH: ganti dengan alamat aplikasi Anda
const string Sso = "https://sso.dev-app.unri.ac.id"; // alamat SSO ini, jangan diubah

var builder = WebApplication.CreateBuilder(args);

builder.Services
    .AddAuthentication(options =>
    {
        options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
        options.DefaultChallengeScheme = Saml2Defaults.Scheme;
    })
    .AddCookie()
    .AddSaml2(options =>
    {
        options.SPOptions.EntityId = new EntityId($"{App}/Saml2");
        options.SPOptions.PublicOrigin = new Uri($"{App}/");
        options.SPOptions.OutboundSigningAlgorithm = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256";
        options.SPOptions.ServiceCertificates.Add(
            new X509Certificate2("saml/sp.pfx", builder.Configuration["Saml:PfxPassword"]));

        options.IdentityProviders.Add(new IdentityProvider(new EntityId($"{Sso}/saml/metadata"), options.SPOptions)
        {
            MetadataLocation = $"{Sso}/saml/metadata",
            LoadMetadata = true,
        });
    });

builder.Services.AddAuthorization();

var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();

app.MapGet("/login", () => Results.Challenge(
    new AuthenticationProperties { RedirectUri = "/" }, [Saml2Defaults.Scheme]));

app.MapGet("/logout", () => Results.SignOut(
    new AuthenticationProperties { RedirectUri = "/" },
    [CookieAuthenticationDefaults.AuthenticationScheme, Saml2Defaults.Scheme]));

app.MapGet("/", (ClaimsPrincipal user) => new
{
    Id = user.FindFirstValue(ClaimTypes.NameIdentifier), // pegawai:123 atau mahasiswa:2107110001
    Type = user.FindFirstValue("account_type"),          // pegawai atau mahasiswa
    Name = user.FindFirstValue("name"),
    Email = user.FindFirstValue("email"),
}).RequireAuthorization();

app.Run();

Alamat yang didaftarkan Contoh

Ganti https://app.unri.ac.id dengan alamat aplikasi Anda. Path di belakangnya adalah bawaan library ini.

Entity IDhttps://app.unri.ac.id/Saml2
ACS URLhttps://app.unri.ac.id/Saml2/Acs
SLO URLhttps://app.unri.ac.id/Saml2/Logout
Login URLhttps://app.unri.ac.id/login
  • Metadata IdP dibaca saat aplikasi mulai. Restart aplikasi bila sertifikat SSO diganti.

Go

github.com/crewjam/saml (samlsp)

// go get github.com/crewjam/saml@latest
package main

import (
	"context"
	"crypto/rsa"
	"crypto/tls"
	"crypto/x509"
	"fmt"
	"net/http"
	"net/url"

	"github.com/crewjam/saml/samlsp"
)

const (
	appURL = "https://app.unri.ac.id" // CONTOH: ganti dengan alamat aplikasi Anda
	ssoURL = "https://sso.dev-app.unri.ac.id" // alamat SSO ini, jangan diubah
)

func main() {
	keyPair, err := tls.LoadX509KeyPair("saml/sp.crt", "saml/sp.key")
	if err != nil {
		panic(err)
	}
	keyPair.Leaf, err = x509.ParseCertificate(keyPair.Certificate[0])
	if err != nil {
		panic(err)
	}

	metadataURL, _ := url.Parse(ssoURL + "/saml/metadata")
	idpMetadata, err := samlsp.FetchMetadata(context.Background(), http.DefaultClient, *metadataURL)
	if err != nil {
		panic(err)
	}

	rootURL, _ := url.Parse(appURL)
	sp, err := samlsp.New(samlsp.Options{
		URL:            *rootURL,
		Key:            keyPair.PrivateKey.(*rsa.PrivateKey),
		Certificate:    keyPair.Leaf,
		IDPMetadata:    idpMetadata,
		SignRequest:    true,
		CookieSameSite: http.SameSiteNoneMode, // wajib HTTPS
	})
	if err != nil {
		panic(err)
	}

	// /saml/metadata dan /saml/acs disediakan oleh middleware.
	http.Handle("/saml/", sp)
	http.Handle("/", sp.RequireAccount(http.HandlerFunc(home)))
	http.ListenAndServe(":8000", nil)
}

func home(w http.ResponseWriter, r *http.Request) {
	claims := samlsp.SessionFromContext(r.Context()).(samlsp.JWTSessionClaims)

	id := claims.Subject                                                    // pegawai:123 atau mahasiswa:2107110001
	accountType := samlsp.AttributeFromContext(r.Context(), "account_type") // pegawai atau mahasiswa
	name := samlsp.AttributeFromContext(r.Context(), "name")

	fmt.Fprintf(w, "%s (%s) %s", name, accountType, id)
}

Alamat yang didaftarkan Contoh

Ganti https://app.unri.ac.id dengan alamat aplikasi Anda. Path di belakangnya adalah bawaan library ini.

Entity IDhttps://app.unri.ac.id/saml/metadata
ACS URLhttps://app.unri.ac.id/saml/acs
SLO URL Tidak ada
Login URLhttps://app.unri.ac.id/
  • samlsp tidak menangani Single Logout. Daftarkan tanpa SLO URL; logout cukup menghapus sesi lokal dengan sp.Session.DeleteSession.

Ruby

ruby-saml 1.18.0 ke atas (contoh dengan Rails)

# Gemfile: gem "ruby-saml", ">= 1.18.0"
#
# config/routes.rb:
#   get  "saml/login",    to: "saml#login"
#   post "saml/acs",      to: "saml#acs"
#   get  "saml/logout",   to: "saml#logout"
#   get  "saml/slo",      to: "saml#slo"
#   get  "saml/metadata", to: "saml#metadata"

class SamlController < ApplicationController
  skip_forgery_protection only: :acs

  APP = "https://app.unri.ac.id" # CONTOH: ganti dengan alamat aplikasi Anda
  SSO = "https://sso.dev-app.unri.ac.id" # alamat SSO ini, jangan diubah

  def login
    auth_request = OneLogin::RubySaml::Authrequest.new
    url = auth_request.create(saml_settings)
    session[:saml_request_id] = auth_request.uuid
    redirect_to url, allow_other_host: true
  end

  def acs
    response = OneLogin::RubySaml::Response.new(
      params[:SAMLResponse],
      settings: saml_settings,
      matches_request_id: session.delete(:saml_request_id)
    )

    unless response.is_valid?
      Rails.logger.warn("Login SSO gagal: #{response.errors.join(', ')}")
      return head :forbidden
    end

    reset_session
    session[:user] = {
      "id" => response.nameid,                       # pegawai:123 atau mahasiswa:2107110001
      "type" => response.attributes["account_type"], # pegawai atau mahasiswa
      "name" => response.attributes["name"],
      "email" => response.attributes["email"]
    }
    session[:saml] = { "name_id" => response.nameid, "session_index" => response.sessionindex }
    redirect_to "/"
  end

  def logout
    settings = saml_settings
    settings.name_identifier_value = session.dig(:saml, "name_id")
    settings.sessionindex = session.dig(:saml, "session_index")

    logout_request = OneLogin::RubySaml::Logoutrequest.new
    url = logout_request.create(settings)
    session[:saml_logout_id] = logout_request.uuid
    redirect_to url, allow_other_host: true
  end

  # Menangani dua hal: SSO meminta logout, atau SSO menjawab logout dari aplikasi ini.
  def slo
    if params[:SAMLRequest]
      logout_request = OneLogin::RubySaml::SloLogoutrequest.new(
        params[:SAMLRequest], settings: saml_settings, get_params: request.query_parameters
      )
      return head :bad_request unless logout_request.is_valid?

      reset_session
      url = OneLogin::RubySaml::SloLogoutresponse.new.create(
        saml_settings, logout_request.id, nil, RelayState: params[:RelayState]
      )
      redirect_to url, allow_other_host: true
    else
      logout_response = OneLogin::RubySaml::Logoutresponse.new(
        params[:SAMLResponse], saml_settings,
        matches_request_id: session[:saml_logout_id], get_params: request.query_parameters
      )
      return head :bad_request unless logout_response.validate && logout_response.success?

      reset_session
      redirect_to "/"
    end
  end

  def metadata
    render xml: OneLogin::RubySaml::Metadata.new.generate(saml_settings)
  end

  private

  def saml_settings
    OneLogin::RubySaml::Settings.new.tap do |s|
      s.sp_entity_id = "#{APP}/saml/metadata"
      s.assertion_consumer_service_url = "#{APP}/saml/acs"
      s.single_logout_service_url = "#{APP}/saml/slo"
      s.idp_entity_id = "#{SSO}/saml/metadata"
      s.idp_sso_service_url = "#{SSO}/saml/sso"
      s.idp_slo_service_url = "#{SSO}/saml/slo"
      s.idp_cert = File.read(Rails.root.join("config/saml/idp.crt"))
      s.certificate = File.read(Rails.root.join("config/saml/sp.crt"))
      s.private_key = File.read(Rails.root.join("config/saml/sp.key"))
      s.name_identifier_format = "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent"
      s.security[:authn_requests_signed] = true
      s.security[:logout_requests_signed] = true
      s.security[:logout_responses_signed] = true
      s.security[:want_assertions_signed] = true
      s.security[:signature_method] = XMLSecurity::Document::RSA_SHA256
      s.security[:digest_method] = XMLSecurity::Document::SHA256
    end
  end
end

Alamat yang didaftarkan Contoh

Ganti https://app.unri.ac.id dengan alamat aplikasi Anda. Path di belakangnya adalah bawaan library ini.

Entity IDhttps://app.unri.ac.id/saml/metadata
ACS URLhttps://app.unri.ac.id/saml/acs
SLO URLhttps://app.unri.ac.id/saml/slo
Login URLhttps://app.unri.ac.id/saml/login
  • Versi sebelum 1.18.0 punya celah pemalsuan tanda tangan (CVE-2025-25291 dan CVE-2025-25292).

Tanpa ubah kode

Apache mod_auth_mellon di depan aplikasi apa pun

# apt install libapache2-mod-auth-mellon
# curl -o /etc/apache2/saml/idp-metadata.xml https://sso.dev-app.unri.ac.id/saml/metadata

<VirtualHost *:443>
    # CONTOH: ganti app.unri.ac.id dan 127.0.0.1:8000 dengan alamat aplikasi Anda
    ServerName app.unri.ac.id

    <Location />
        MellonEnable info
        MellonSPentityId "https://app.unri.ac.id/mellon/metadata"
        MellonSPPrivateKeyFile /etc/apache2/saml/sp.key
        MellonSPCertFile /etc/apache2/saml/sp.crt
        MellonIdPMetadataFile /etc/apache2/saml/idp-metadata.xml
        MellonEndpointPath /mellon
        MellonSignatureMethod rsa-sha256
        MellonSecureCookie On
        MellonCookieSameSite None
    </Location>

    # Bagian yang wajib login.
    <Location /app>
        AuthType Mellon
        MellonEnable auth
        Require valid-user

        # Diteruskan ke aplikasi di belakangnya, apa pun bahasanya.
        RequestHeader set X-SSO-Id   "%{MELLON_NAME_ID}e"
        RequestHeader set X-SSO-Type "%{MELLON_account_type}e"
        RequestHeader set X-SSO-Name "%{MELLON_name}e"
    </Location>

    ProxyPass        /app http://127.0.0.1:8000/app
    ProxyPassReverse /app http://127.0.0.1:8000/app
</VirtualHost>

Alamat yang didaftarkan Contoh

Ganti https://app.unri.ac.id dengan alamat aplikasi Anda. Path di belakangnya adalah bawaan library ini.

Entity IDhttps://app.unri.ac.id/mellon/metadata
ACS URLhttps://app.unri.ac.id/mellon/postResponse
SLO URLhttps://app.unri.ac.id/mellon/logout
Login URLhttps://app.unri.ac.id/app
  • Cocok untuk aplikasi lama atau bahasa yang tidak punya library SAML. Aplikasi cukup membaca header X-SSO-Id dan X-SSO-Type.
  • Aplikasi di belakangnya hanya boleh bisa dijangkau lewat Apache. Bila bisa diakses langsung, header itu bisa dipalsukan.

Bahasa lain

Library SAML 2.0 Service Provider apa pun bisa dipakai bila mendukung:

  • AuthnRequest lewat HTTP-Redirect yang ditandatangani RSA-SHA256 (parameter SigAlg dan Signature).
  • Menerima SAML Response lewat HTTP-POST dan memeriksa tanda tangan Response maupun Assertion.
  • Memeriksa InResponseTo, Audience, Destination, dan masa berlaku.
  • Opsional: Single Logout lewat HTTP-Redirect, juga bertanda tangan.

Aturan wajib

  1. Kenali pengguna dari NameID, bukan dari email. Mahasiswa bisa mengubah email sendiri, dan sebagian email dipakai bersama.
  2. Wajibkan tanda tangan dan jangan menonaktifkan validasi, termasuk saat uji coba.
  3. Periksa InResponseTo. Simpan ID AuthnRequest saat login dimulai, lalu cocokkan di ACS. Tanpa ini respons curian bisa dipakai ulang.
  4. Kecualikan ACS dari proteksi CSRF. POST datang dari SSO, bukan dari form aplikasi.
  5. Buat sesi baru setelah login (regenerate session ID).
  6. Pakai HTTPS untuk aplikasi.
  7. Simpan sp.key di luar git dengan izin baca hanya untuk aplikasi.

Cookie dan SameSite

Bila alamat aplikasi dan SSO sama-sama di *.unri.ac.id, keduanya dianggap satu situs dan cookie sesi biasa sudah cukup. Bila beda situs, browser tidak mengirim cookie SameSite=Lax pada POST dari SSO, sehingga ID AuthnRequest yang disimpan di sesi tidak terbaca dan validasi InResponseTo gagal. Simpan ID itu di cookie terpisah dengan SameSite=None; Secure, atau set cookie sesi ke SameSite=None; Secure.

Masalah umum

GejalaPenyebab
"Aplikasi ini belum terdaftar di SSO UNRI"Issuer di AuthnRequest tidak sama persis dengan Entity ID yang didaftarkan.
SSO menolak permintaan loginTanda tangan AuthnRequest tidak ada atau tidak cocok dengan sertifikat terdaftar, ACS di permintaan berbeda dari yang didaftarkan, atau jam server melenceng lebih dari 120 detik.
Aplikasi menolak respons karena tanda tanganidp.crt di aplikasi bukan sertifikat SSO yang sedang dipakai.
Aplikasi menolak respons karena InResponseToID AuthnRequest hilang dari sesi. Lihat Cookie dan SameSite.
Aplikasi menolak respons karena Destination atau AudienceAlamat aplikasi di konfigurasi berbeda dari alamat publiknya, biasanya karena berjalan di balik reverse proxy.
"Akun Anda tidak memiliki akses ke ..."Jenis akun pengguna tidak termasuk yang diizinkan untuk aplikasi ini.