Cara kerja
- Halaman dilindungiUser membuka halaman aplikasi Anda tanpa sesi lokal → aplikasi mengarahkan ke
/auth/login. - Ke auth.sassly.aiSDK menyimpan
state,nonce, dan PKCE di session, lalu redirect kehttps://auth.sassly.ai/oauth/authorize. - Login (atau SSO)Bila user sudah login di aplikasi Sassly lain di browser yang sama, auth langsung kembali tanpa layar apa pun. Bila belum, user memilih akun Google.
- CallbackAuth kembali ke
/auth/callbackmembawacode. Aplikasi menukarnya server-to-server menjadi token dan memverifikasi ID token (RS256, JWKS). - Tautkan user lokalAkun Sassly (
sub, mis.usr_…) dipetakan ke baris user aplikasi lewat kolomsassly_sub. Role, tenant, dan status aktif tetap urusan aplikasi. - RevalidasiTiap 5 menit SDK mengecek ulang sesi pusat. Logout, blokir, atau pencabutan sesi di pusat ikut mengeluarkan user dari aplikasi.
Auth hanya mengurus identitas. Siapa yang boleh masuk ke aplikasi Anda, role-nya, dan tenant-nya tetap ditentukan database aplikasi Anda.
Persiapan
- PHP ≥ 8.1 dengan ekstensi
curl,openssl,json; session PHP aktif. - Folder cache yang bisa ditulis user PHP-FPM (untuk cache JWKS).
- Tabel user dengan kolom email dan penanda aktif.
- Data untuk admin Sassly:
client_id(huruf kecil, angka, tanda minus, mis.kobe), URL production aplikasi, dan URL callback.
Endpoint discovery: https://auth.sassly.ai/.well-known/openid-configuration · kunci publik: /.well-known/jwks.json.
Aturan wajib
- Salin
SasslyAuthClient.phpapa adanya. Jangan diedit per aplikasi; upgrade dengan mengganti seluruh file. - Jangan mematikan verifikasi TLS. Untuk sertifikat lokal self-signed, pakai opsi
ca_file. - Token hanya masuk lewat
handleCallback()(server-to-server). Jangan menerima token dari query string, form, atau JavaScript, dan jangan menaruh token di HTML/JS. - Tautkan user lewat email hanya bila
email_verified === truedan baris lokalsassly_sub IS NULL. Jangan pernah menimpasassly_subyang sudah terisi. - Callback tidak boleh otomatis redirect ke
/auth/loginsaat error atau saat user ditolak (redirect loop). Tampilkan halaman. - Cookie session aplikasi: nama unik per aplikasi, host-only (tanpa
domain),HttpOnly,SameSite=Lax,Securedi HTTPS. Bila sebelumnya memakai cookie ber-domain=.sassly.ai, pakai nama baru. - Logout hanya lewat POST + CSRF.
redirect_uridi config harus identik dengan yang didaftarkan (tanpa normalisasi, termasuk slash akhir).- Setiap halaman, endpoint AJAX, dan unduhan file yang dilindungi memanggil
sassly_require_login(). - Client secret disimpan di
.envaplikasi: tidak di-commit, tidak ditulis ke log, tidak ditempel ke chat.
1Daftarkan aplikasi
Hubungi admin Sassly dengan client_id, redirect URI, dan URL setelah logout. Admin menjalankan di server auth:
php bin/auth client:create --id=kobe --name="Kobe" --type=confidential \
--redirect=https://kobe.sassly.ai/auth/callback --post-logout=https://kobe.sassly.ai/client_secret (awalan sas_cs_) dicetak sekali; admin menaruhnya langsung di .env aplikasi. Redirect URI tambahan (mis. staging) didaftarkan dengan client:add-redirect. Aplikasi mobile memakai --type=public (PKCE tanpa secret, refresh token dirotasi).
2Pasang file
Unduh lalu letakkan di aplikasi (sesuaikan path bila struktur aplikasi berbeda). Jalankan php -l untuk setiap file setelah disalin.
- SasslyAuthClient.phpSDK v1.0.0 →
lib/sassly-auth/. Jangan diedit. - sassly_auth_bootstrap.phpLem SDK ↔ aplikasi →
lib/sassly-auth/. Ubah hanya bagianADAPT. - sassly_auth_hooks.phpPemetaan user (satu-satunya logika khusus aplikasi) →
lib/sassly-auth/. - auth/login.phpRoute
GET /auth/login. - auth/callback.phpRoute
GET /auth/callback(redirect URI terdaftar). - auth/logout.phpRoute
POST /auth/logout(CSRF). - auth/choose.phpPilih ruang kerja — hanya aplikasi multi-tenant.
- api_bearer_example.phpContoh API dengan access token (opsional).
- migration_users_sassly_sub.sqlQuery cek + kolom
sassly_sub. - env.exampleVariabel konfigurasi.
Bootstrap Sassly di-include dari bootstrap aplikasi, sebelum output apa pun. Route template me-require bootstrap aplikasi lewat __DIR__ . '/../../bootstrap.php' — sesuaikan path itu (komentar ADAPT).
3Konfigurasi .env
SASSLY_AUTH_ISSUER=https://auth.sassly.ai
SASSLY_AUTH_CLIENT_ID=kobe
# Dicetak sekali oleh admin saat client:create (jangan di-commit)
SASSLY_AUTH_CLIENT_SECRET=
# Harus identik dengan redirect URI terdaftar
SASSLY_AUTH_REDIRECT_URI=https://kobe.sassly.ai/auth/callback
SASSLY_AUTH_POST_LOGOUT_REDIRECT_URI=https://kobe.sassly.ai/
# Bisa ditulis user PHP-FPM
SASSLY_AUTH_CACHE_DIR=/var/www/kobe/storage/cache/sassly-auth
SASSLY_AUTH_REVALIDATE_INTERVAL=300
# Unik per aplikasi, host-only, belum pernah dipakai dengan Domain=.sassly.ai
APP_SESSION_NAME=kobe_sidLokal (auth di http://127.0.0.1:8701): tambahkan SASSLY_AUTH_ALLOW_INSECURE_HTTP=1; untuk sertifikat self-signed pakai SASSLY_AUTH_CA_FILE. Tulis komentar di baris sendiri — sebagian loader .env menganggap # … setelah nilai sebagai bagian nilai.
Di sassly_auth_bootstrap.php, hanya bagian ADAPT yang diubah:
sassly_auth_config()membaca dari sistem config aplikasi. PHP-FPM tidak melihat env shell — nilai harus sampai lewat loader .env aplikasi atauenv[…]pool.sassly_session_name()mengembalikan nama session unik aplikasi.sassly_paths()berisi URL route auth yang sebenarnya.
Ganti session_start() aplikasi dengan sassly_session_start(), atau samakan parameter cookie-nya persis.
4Database
- Jalankan query cek di migration_users_sassly_sub.sql. Email ganda di dalam satu tenant harus dibereskan dulu.
- Tambahkan kolom penaut lewat mekanisme migrasi aplikasi:
ALTER TABLE users
ADD COLUMN sassly_sub VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL DEFAULT NULL;
-- (a) satu orang = satu baris user:
CREATE UNIQUE INDEX uq_users_sassly_sub ON users (sassly_sub);
-- (b) orang yang sama bisa punya baris di beberapa tenant (pakai halaman pilih ruang kerja):
-- CREATE INDEX ix_users_sassly_sub ON users (sassly_sub);5Hooks aplikasi
File sassly_auth_hooks.php adalah satu-satunya tempat logika khusus aplikasi:
| Fungsi | Yang disesuaikan |
|---|---|
sassly_app_pdo() | Koneksi PDO aplikasi (ERRMODE_EXCEPTION). |
sassly_find_or_link_users() | Nama tabel/kolom + kondisi aktif. Cari lewat sassly_sub; tautkan lewat email hanya bila email_verified === true dan sassly_sub IS NULL; kembalikan 0, 1, atau beberapa baris aktif. |
sassly_load_active_user() | Harus null bila user nonaktif atau tidak tertaut lagi ke sub ini (user yang dinonaktifkan langsung kehilangan akses). |
sassly_user_label() | Nama tenant + role untuk halaman pilih ruang kerja. |
sassly_on_login() | Isi key $_SESSION yang dibaca kode aplikasi (mis. user_id, company_id, role), update login terakhir. |
sassly_app_session_keys() | Semua key yang diisi sassly_on_login() (dihapus saat sesi Sassly berakhir). |
function sassly_find_or_link_users(array $identity): array
{
$pdo = sassly_app_pdo();
$sub = (string) $identity['sub'];
$email = is_string($identity['email'] ?? null) ? strtolower(trim($identity['email'])) : '';
if ($email !== '' && ($identity['email_verified'] ?? false) === true) {
$pdo->prepare('UPDATE users SET sassly_sub = ? WHERE LOWER(email) = ? AND sassly_sub IS NULL')
->execute([$sub, $email]);
}
$st = $pdo->prepare('SELECT * FROM users WHERE sassly_sub = ? AND is_active = 1 ORDER BY id');
$st->execute([$sub]);
return $st->fetchAll(PDO::FETCH_ASSOC);
}email_verified hanya true bila Google berwenang atas alamat itu (Gmail, Google Workspace) atau domainnya dipercaya di auth. Email kantor non-Google tetap bisa login ke auth tetapi tidak tertaut otomatis: halaman "Akun belum terdaftar" menampilkan ID akun Sassly (usr_…), lalu admin aplikasi mengisi sassly_sub baris user itu secara manual.
6Route login
- login.php — membuat state/nonce/PKCE lalu redirect ke auth. Bila sudah login, langsung ke
return_to. - callback.php — verifikasi, pemetaan user, lalu salah satu: login, halaman pilih ruang kerja, atau halaman 403 "Akun belum terdaftar" (tidak memulai sesi; tombol "Keluar / ganti akun" memakai id_token yang ditolak sebagai hint).
- logout.php — POST + CSRF →
prepareLogout()→ hancurkan session aplikasi → redirect ke logout auth (mengakhiri sesi pusat di browser itu). - choose.php — hanya aplikasi multi-tenant; hapus bila tidak dipakai, beserta cabang
count($users) > 1di callback.
7Lindungi halaman & AJAX
// Di guard aplikasi (awal setiap halaman / endpoint AJAX / unduhan yang dilindungi):
$user = sassly_require_login(); // baris user lokal yang aktif, atau request berakhir di sinisassly_require_login() sudah menangani: redirect ke login untuk halaman (GET menyimpan return_to), 401 JSON untuk AJAX (X-Requested-With: XMLHttpRequest atau Accept: application/json), dan 403 untuk user yang dinonaktifkan di aplikasi. Frontend menangani 401 di wrapper fetch global:
if (res.status === 401) {
window.location = '/auth/login?return_to=' + encodeURIComponent(location.pathname + location.search);
}Tombol keluar berupa form POST:
<form method="post" action="/auth/logout">
<input type="hidden" name="csrf" value="<?= sassly_e(sassly_csrf_token()) ?>">
<button type="submit">Keluar</button>
</form>8API dengan access token (opsional)
Untuk endpoint yang dipanggil app mobile Sassly Suites atau aplikasi lain. Access token berlaku 15 menit, aud = sassly-api. Petakan user lewat sassly_sub saja (tanpa penautan email). Contoh lengkap: api_bearer_example.php.
$token = SasslyAuthClient::bearerTokenFromRequest();
try {
$claims = sassly_auth()->verifyAccessToken((string) $token, 'sassly-api');
} catch (SasslyAuthException $e) {
http_response_code($e->reason === 'insufficient_scope' ? 403 : 401);
exit;
}
// $claims['sub'] → SELECT * FROM users WHERE sassly_sub = ? AND is_active = 1Memanggil API Sassly lain dari aplikasi web: sassly_auth()->accessToken() mengembalikan access token yang masih berlaku (diperbarui otomatis).
9Pindah dari login lama
Simpan login lama di balik saklar config (AUTH_MODE=legacy|sassly) sampai integrasi terverifikasi di staging, lalu hapus kode dan secret Google OAuth lama aplikasi. Halaman login lama diarahkan ke /auth/login.
10Verifikasi
- Halaman dilindungi → login → kembali ke halaman semula.
- Sudah login di aplikasi Sassly lain → masuk tanpa layar login.
- Akun tidak terdaftar → 403 "Akun belum terdaftar"; "Keluar / ganti akun" berfungsi.
- User dinonaktifkan di DB aplikasi → request berikutnya 403, tanpa redirect loop.
- Baris yang tertaut ke sub lain tidak diambil alih.
- Akun dengan email_verified=false tidak tertaut otomatis; setelah sassly_sub diisi manual, akun itu bisa masuk.
- Browser yang masih menyimpan cookie session lama aplikasi tetap bisa login.
- Multi-tenant (bila ada) → halaman pilih ruang kerja.
- Logout → kembali ke halaman depan aplikasi; aplikasi lain keluar ≤ revalidate_interval.
- AJAX setelah sesi berakhir → 401 → frontend ke login.
- /auth/login?return_to=//evil.com → mendarat di /.
- GET logout dan POST tanpa CSRF ditolak.
- Tidak ada warning/notice PHP di log; tidak ada token di HTML/JS; cookie session tanpa Domain.
API SDK
| Method | Kegunaan |
|---|---|
new SasslyAuthClient(array $config) | Validasi config; melempar SasslyAuthException('config_invalid'). |
buildLoginUrl($returnTo, $options) | URL authorize; opsi prompt (none/login), login_hint. |
redirectToLogin($returnTo, $options) | Redirect ke auth. |
handleCallback(?array $query) | Verifikasi callback → identity (sub, email, email_verified, name, picture, sid, return_to, claims, tokens). |
startSession(array $identity) | Login lokal (regenerate session id). |
check() | NONE / VALID / ENDED; revalidasi tiap interval. |
identity() | Data user tanpa token. |
accessToken() | Access token segar untuk memanggil API Sassly. |
prepareLogout($state, $idTokenHint) | URL logout auth; membersihkan data Sassly lokal. |
clearLocal() | Hapus data Sassly lokal. |
verifyAccessToken($jwt, 'sassly-api', $scope) | Verifikasi bearer token di API. |
SasslyAuthClient::bearerTokenFromRequest() | Ambil bearer token dari request. |
SasslyAuthClient::sanitizeReturnTo($path, '/') | Cegah open redirect. |
Semua kegagalan berupa SasslyAuthException dengan ->reason, ->oauthError, dan ->returnTo — artinya ada di Troubleshooting.
Kontrak protokol (v1)
Endpoint
| Endpoint | Path | Catatan |
|---|---|---|
| Discovery | /.well-known/openid-configuration | SDK memakai path tetap. |
| JWKS | /.well-known/jwks.json | Cache 1 jam; diambil ulang sekali bila kid tidak dikenal. |
| Authorize | /oauth/authorize | response_type=code, state, nonce, PKCE S256 wajib. |
| Token | /oauth/token | Form-urlencoded, HTTP Basic (confidential). |
| Userinfo | /oauth/userinfo | Bearer access token. |
| Logout | /oauth/logout | id_token_hint, post_logout_redirect_uri terdaftar persis. |
| Revoke | /oauth/revoke | RFC 7009; selalu 200. |
| Cabut sesi | /api/v1/sessions/revoke | Khusus client dengan izin (mis. HR): body sub atau email. |
Waktu
| Item | Nilai |
|---|---|
| Authorization code | 60 detik, sekali pakai |
| ID token | 5 menit (hanya untuk login) |
| Access token | 15 menit |
| Sesi pusat | idle 7 hari, maksimal 30 hari |
| Revalidasi SDK | tiap 300 detik (minimal 30) |
| Toleransi auth tak terjangkau | 1800 detik (max_stale), lalu sesi lokal diakhiri |
| Toleransi jam | 60 detik |
Token endpoint
POST /oauth/token Authorization: Basic base64(urlencode(client_id) ":" urlencode(client_secret))
grant_type=authorization_code&code=…&redirect_uri=…&code_verifier=…
→ {"access_token":"…","token_type":"Bearer","expires_in":900,"id_token":"…","refresh_token":"rt_…","scope":"openid email profile"}
grant_type=refresh_token&refresh_token=rt_…
→ {"access_token":"…","token_type":"Bearer","expires_in":900,"scope":"openid email profile"}
(client confidential tidak menerima refresh token baru; client public menerima token baru setiap kali)| HTTP | error | Arti bagi aplikasi |
|---|---|---|
| 400 | invalid_grant | Code tidak valid/terpakai/kedaluwarsa, PKCE atau redirect_uri tidak cocok, ATAU sesi pusat sudah berakhir (logout, diblokir, dicabut, kedaluwarsa). |
| 401 | invalid_client | client_id/secret salah atau client dinonaktifkan. |
| 400 | invalid_request | Parameter wajib kosong, body bukan form, atau dua metode autentikasi sekaligus. |
| 400 | unsupported_grant_type | Grant selain dua di atas. |
| 5xx | server_error | Gangguan di auth. |
ID token & access token
| Klaim | Isi |
|---|---|
iss | Issuer persis: https://auth.sassly.ai |
sub | ID akun Sassly, usr_ + 32 hex. Kunci penautan user — stabil selamanya untuk satu akun Google. |
aud | ID token: client_id. Access token: sassly-api (+ klaim client_id, scope, jti). |
iat, exp, auth_time | Integer epoch detik. |
nonce, sid | Nonce dari SDK; ID sesi pusat ses_ + 32 hex. |
email, email_verified | Email Google (huruf kecil); email_verified boolean — true hanya bila Google berwenang atas alamat itu atau domainnya dipercaya. |
name, picture | Profil Google, bila ada. |
Header JWT: {"alg":"RS256","typ":"JWT"|"at+jwt","kid":"…"}. Kunci RSA 2048; kunci baru diterbitkan di JWKS minimal 2 jam sebelum dipakai menandatangani.
Logout & identitas
- Dengan
id_token_hintyang cocok dengan sesi di browser: logout langsung. Tanpa hint yang cocok: auth meminta konfirmasi. - Yang dicabut: sesi pusat di browser itu + semua refresh token sesi itu. Aplikasi lain di browser yang sama keluar pada revalidasi berikutnya.
- Akun Google yang dihapus lalu dibuat ulang dengan email sama mendapat
subbaru — jangan ditautkan otomatis ke baris yang sudah tertaut kesublama. - Auth tidak menyimpan role, tenant, atau status aktif per aplikasi.
Troubleshooting
Berdasarkan SasslyAuthException->reason
| reason | Penyebab umum | Perbaikan |
|---|---|---|
config_invalid | Nilai config kosong; issuer/redirect_uri bukan https; client_id tidak cocok [a-z0-9-]; scope tanpa openid. Sering: .env tidak dimuat di PHP-FPM. | Cek nilai config di request web (bukan CLI). Issuer http lokal: SASSLY_AUTH_ALLOW_INSECURE_HTTP=1. |
session_not_started | SDK dipanggil sebelum session dimulai. | Panggil sassly_session_start() lebih dulu. |
headers_sent | Ada output sebelum redirect: BOM, spasi sebelum <?php, echo debug, warning PHP. | Pesan error menyebut file & baris asal output; hapus output itu. |
state_missing / state_mismatch | Callback diulang (refresh/back), login dimulai > 10 menit lalu, tab lain, atau cookie session aplikasi tidak tersimpan. | Klik "Coba lagi". Bila selalu terjadi: cek cookie session (Secure di http lokal, domain salah, nama bentrok). |
iss_mismatch | Issuer di config berbeda dengan issuer server (http/https, port). | Samakan SASSLY_AUTH_ISSUER persis. |
authorize_error + login_required | prompt=none tanpa sesi pusat. | Normal; template callback mengarahkan ke return_to. |
authorize_error + invalid_request | Parameter authorize ditolak (state/PKCE/nonce/prompt). | Jangan bangun URL authorize sendiri; pakai buildLoginUrl(). |
invalid_grant (callback) | Code dipakai dua kali, > 60 detik, redirect_uri config ≠ yang dipakai, atau sesi pusat dicabut di tengah login. | Samakan SASSLY_AUTH_REDIRECT_URI dengan URL callback; cek jam server (NTP). |
invalid_grant (check) | Sesi pusat berakhir: logout di aplikasi lain, diblokir, dicabut, idle 7 hari / maks. 30 hari. | Normal; SDK mengembalikan ENDED dan user diarahkan ke login. |
invalid_client | Secret salah, secret lama lewat masa transisi rotasi, client dinonaktifkan. | Perbarui secret di .env; hubungi admin Sassly. |
network_error | Auth tidak terjangkau: DNS, firewall, timeout, TLS. | Lihat pesan cURL di log. curl 60 = CA tidak dipercaya → SASSLY_AUTH_CA_FILE. |
token_invalid | Tanda tangan/iss/aud/exp/typ salah: issuer atau client_id config salah, jam melenceng > 60 detik, ID token dipakai sebagai access token. | Samakan config; aktifkan NTP; hapus cache jwks-*.json bila kunci dirotasi. |
jwks_unavailable | JWKS tidak bisa diambil dan tidak ada cache. | Cek akses ke /.well-known/jwks.json; jangan blokir /.well-known/ di Nginx. |
identity_invalid | startSession() dipanggil dengan array buatan sendiri. | Berikan array hasil handleCallback() apa adanya. |
Berdasarkan gejala
| Gejala | Penyebab & perbaikan |
|---|---|
| Redirect loop aplikasi ↔ auth | Callback/guard redirect otomatis ke login saat user ditolak, atau cookie session tidak tersimpan. Pakai template callback dan sassly_require_login() apa adanya; cookie host-only, SameSite=Lax, Secure hanya di https. |
| "Aplikasi tidak dikenal" | client_id belum terdaftar atau dinonaktifkan — hubungi admin Sassly. |
| "redirect_uri tidak terdaftar" | URI di config ≠ URI terdaftar (slash akhir, .php, http/https, port). Samakan, atau minta admin menambah redirect. |
| 403 "Akun belum terdaftar" untuk user yang ada | Email di aplikasi ≠ email Google; baris nonaktif; atau baris sudah tertaut ke sassly_sub lain. |
| Email kantor non-Google selalu 403 | email_verified=false → tidak ada penautan otomatis. Isi sassly_sub manual dengan ID dari halaman 403. |
| Logout di satu aplikasi, aplikasi lain masih login beberapa menit | Sesuai desain: keluar pada revalidasi berikutnya (≤ revalidate_interval, minimal 30 detik). |
| Auth meminta konfirmasi saat logout | Tidak ada id_token_hint yang cocok (session aplikasi sudah hilang). Normal. |
| Lancar di CLI, gagal di web | Config/.env tidak sampai ke PHP-FPM; muat lewat mekanisme config aplikasi. |
| AJAX mendapat HTML halaman login | Kirim header X-Requested-With: XMLHttpRequest atau Accept: application/json. |
Untuk Claude Code
Skill sassly-auth-integration berisi SDK, template, kontrak, dan troubleshooting di atas, sehingga Claude Code bisa mengaudit aplikasi, menyusun rencana, dan memasang integrasi. Pasang lalu minta: "integrasikan login auth.sassly.ai ke aplikasi ini".
curl -fsSLO https://auth.sassly.ai/assets/sdk/sassly-auth-integration.zip
unzip -o sassly-auth-integration.zip -d ~/.claude/skills/