← API notifikasi JS Staff

Set Up "Log in with JS Staff"

Employees log into your app with their JS Staff account. Your app never sees their password.

What you get

{
  "user_id": 3,
  "nama": "Sela",
  "email": "sela@contoh.com",
  "peran": "staff",
  "tenant_id": 1,
  "tenant_nama": "Jidanshoppu",
  "tenant_slug": "jidanshoppu"
}

That’s all. peran (role) holds staff, supervisor, or owner. Your app cannot read anyone’s password, pay, attendance, advances, or bank account.

Why a redirect, not a "send email+password" API. An API like that would defeat the new-device OTP this portal relies on. The entire point of OTP is that a password alone is not enough to log in — the moment one door accepts a password alone, a password thief just walks through that door.

With a redirect, no such door exists: login always happens on our page, complete with OTP and its trusted devices.

The flow

[1] Employee clicks "Log in with JS Staff" in your app ↓ [2] You redirect them to https://staff.jidanshoppu.com/sso/authorize.php?... ↓ (they log in there + OTP if new device + consent screen) [3] They return to sso-callback.php?code=...&state=... ↓ [4] Your SERVER exchanges code + secret at /sso/token.php ← server-to-server ↓ [5] You get their identity → you build your own session

1 · Request these two values

From the JS Staff platform admin (they register it at /super/apps.php):

ValueExample
client_idapp_a1b2c3d4e5f6a7b8
client_secretsec_1a2b3c… — shown only once

You must state your redirect URI when registering. The rule is strict:

Why so strict. This is the single most important guardrail in the whole SSO flow. Loosen it even slightly — prefix, subdomain, wildcard — and an attacker only needs to send your employee an authorize link with their own callback address. The victim logs in on our genuine page as usual, nothing looks suspicious, and the code lands on the attacker’s server.

2 · Configuration

<?php
// sso-config.php — JANGAN di-commit. Isi dari env var.
const SSO_BASE      = 'https://staff.jidanshoppu.com';
const SSO_CLIENT_ID = 'app_xxxxxxxxxxxxxxxx';
const SSO_SECRET    = 'sec_xxxxxxxxxxxxxxxx';
const SSO_REDIRECT  = 'https://order.jidanshoppu.com/sso-callback.php';

3 · Login button

<?php
// sso-mulai.php
require 'sso-config.php';
session_start();

/*
 * `state` WAJIB. Acak, disimpan di sesi, dicocokkan lagi di callback.
 * Tanpa ini, penyerang bisa mengirim link callback berisi kode MILIK DIA ke korban —
 * korban jadi login sebagai akun penyerang, lalu mengetik data ke akun itu tanpa sadar.
 */
$_SESSION['sso_state'] = bin2hex(random_bytes(16));

$url = SSO_BASE . '/sso/authorize.php?' . http_build_query([
    'client_id'    => SSO_CLIENT_ID,
    'redirect_uri' => SSO_REDIRECT,
    'state'        => $_SESSION['sso_state'],
]);

header('Location: ' . $url);
exit;

The button is enough: <a href="/sso-mulai.php">Masuk pakai JS Staff</a>

4 · Callback

<?php
// sso-callback.php
require 'sso-config.php';
session_start();

// --- Orangnya menolak di layar persetujuan ---
if (isset($_GET['error'])) {
    exit('Login dibatalkan.');
}

// --- Cocokkan state DULU, sebelum menyentuh code ---
$state = (string)($_GET['state'] ?? '');

if ($state === ''
    || !isset($_SESSION['sso_state'])
    || !hash_equals($_SESSION['sso_state'], $state)) {
    // hash_equals, bukan ===: perbandingan biasa berhenti di huruf pertama yang beda,
    // dan selisih waktunya bisa dipakai menebak nilainya.
    exit('State tidak cocok. Ulangi dari awal.');
}
unset($_SESSION['sso_state']);   // sekali pakai

$code = (string)($_GET['code'] ?? '');
if ($code === '') {
    exit('Kode tidak ada.');
}

// --- Tukar kode jadi identitas (SERVER ke server) ---
$ch = curl_init(SSO_BASE . '/sso/token.php');
curl_setopt_array($ch, [
    CURLOPT_POST           => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => 10,
    CURLOPT_POSTFIELDS     => http_build_query([
        'client_id'     => SSO_CLIENT_ID,
        'client_secret' => SSO_SECRET,
        'code'          => $code,
        'redirect_uri'  => SSO_REDIRECT,   // harus sama dengan langkah 3
    ]),
]);

$body = curl_exec($ch);
$kode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($kode !== 200) {
    // Jangan tampilkan $body apa adanya ke pengguna — itu pesan untuk kamu, bukan dia.
    error_log('SSO gagal: ' . $kode . ' ' . $body);
    exit('Login gagal. Coba lagi.');
}

$data = json_decode((string)$body, true);
if (!is_array($data) || empty($data['ok'])) {
    exit('Login gagal. Coba lagi.');
}

$u = $data['user'];

// --- Mulai dari sini urusan kamu ---
session_regenerate_id(true);   // cegah session fixation

$_SESSION['jss_user_id']   = $u['user_id'];
$_SESSION['jss_tenant_id'] = $u['tenant_id'];
$_SESSION['nama']          = $u['nama'];
$_SESSION['peran']         = $u['peran'];

header('Location: /app.php');
exit;
This step must be from the SERVER, not the browser. A client_secret passing through the browser is the same as a secret shared with everyone.

Exchange the code right away. It lives for 60 seconds and is single-use — it travels through the browser URL, and URLs land in history, server logs, and the Referer header.

5 · Mapping to an account on your side

user_id is globally unique, tenant_id marks the company. Both are stable — they never change even if names are.

Don’t match by email. Email can be changed by the owner, and matching by email means an account can silently change hands the moment the email is changed. Store jss_user_id.
ALTER TABLE staff ADD COLUMN jss_user_id   INTEGER NULL;
ALTER TABLE staff ADD COLUMN jss_tenant_id INTEGER NULL;
CREATE UNIQUE INDEX uq_staff_jss ON staff (jss_user_id);

First-time flow:

  1. Look up the account with jss_user_id = :user_id → found? log in.
  2. Not found? Match tenant_slug to a store on your side, create the staff account, store their jss_user_id.
  3. tenant_slug unrecognized? Reject it. Don’t silently create a new store from SSO data — anyone with a JS Staff account could trigger that.

What’s required & what’s not

Required

Not needed

If it fails

SymptomUsually caused by
"Redirect address doesn’t match" page redirect_uri differs from what was registered. Check trailing slash, http vs https, query string.
"Unknown application" page client_id is wrong, or the app was revoked by the platform admin.
401 at /sso/token.php Code expired (>60 seconds), already used, client_secret wrong, or redirect_uri differs from step 3.
Returns with ?error=access_denied The employee pressed Deny. Not a bug.

401 deliberately uses the same message for every cause — distinguishing "wrong code" from "code already used" gives an attacker a way to map which codes ever existed, and doesn’t help anyone whose integration is correct. Check the four things above one by one.

Testing

  1. Ask the platform admin to register a second app just for testing.
  2. Log in with a real staff account.
  3. The consent screen should appear once. The second time it should pass straight through.
  4. Revoke access at My Profile → Apps allowed to use your account → the consent screen should appear again.