config['issuer']), 3600, function () { $doc = Http::timeout(10)->acceptJson() ->get($this->config['issuer'].'/.well-known/openid-configuration') ->throw()->json(); if (($doc['issuer'] ?? null) !== $this->config['issuer']) { throw new SsoException('Issuer di discovery tidak sama dengan XGUARD_ISSUER.'); } return $doc; }); } /** * Alamat halaman login X-Guard. state, nonce, dan code_verifier disimpan di sesi untuk diperiksa saat kembali. * $silent = true: tanpa formulir (prompt=none). Bila belum ada sesi X-Guard, X-Guard menjawab login_required. */ public function authorizationUrl(bool $silent = false): string { $state = Str::random(40); $nonce = Str::random(40); $verifier = Str::random(64); // Disimpan per state, jadi dua tab yang login bersamaan tidak saling menimpa. session()->put('xguard_flow.'.$state, compact('nonce', 'verifier') + ['silent' => $silent]); $query = [ 'client_id' => $this->config['client_id'], 'redirect_uri' => $this->config['redirect_uri'], 'response_type' => 'code', 'scope' => $this->config['scopes'], 'state' => $state, 'nonce' => $nonce, 'code_challenge' => self::base64url(hash('sha256', $verifier, true)), 'code_challenge_method' => 'S256', ]; if ($silent) { $query['prompt'] = 'none'; } return $this->discovery()['authorization_endpoint'].'?'.http_build_query($query, '', '&', PHP_QUERY_RFC3986); } /** * Proses kembalinya browser dari X-Guard: periksa state, tukar kode dengan token, verifikasi ID token. * * @return array{claims: object, access: ?object, id_token: string} * * @throws SsoException state tidak cocok, atau token tidak sah * @throws SsoDenied X-Guard menolak (mis. login_required, access_denied) */ public function handleCallback(array $query): array { $state = (string) ($query['state'] ?? ''); $flow = preg_match('/^[A-Za-z0-9]{40}$/', $state) ? session()->pull('xguard_flow.'.$state) : null; if (! $flow) { throw new SsoException('Permintaan login tidak dikenali (state tidak cocok). Ulangi dari tombol Masuk.'); } if (isset($query['error'])) { throw new SsoDenied((string) $query['error'], (string) ($query['error_description'] ?? ''), (bool) $flow['silent']); } if (empty($query['code'])) { throw new SsoException('X-Guard tidak mengirim kode login.'); } // Penukaran kode terjadi antarserver, dengan client secret (client_secret_basic). $tokens = Http::timeout(10)->asForm()->acceptJson() ->withBasicAuth(rawurlencode($this->config['client_id']), rawurlencode($this->config['client_secret'])) ->post($this->discovery()['token_endpoint'], [ 'grant_type' => 'authorization_code', 'code' => $query['code'], 'redirect_uri' => $this->config['redirect_uri'], 'code_verifier' => $flow['verifier'], ])->throw()->json(); $claims = $this->verify((string) ($tokens['id_token'] ?? '')); if (! hash_equals($flow['nonce'], (string) ($claims->nonce ?? ''))) { throw new SsoException('ID token bukan untuk permintaan login ini (nonce tidak cocok).'); } // Access token X-Guard juga JWT; di sini dibaca untuk menampilkan klaim organisasi (org) dan scope (scp). $access = null; try { $access = $this->verify((string) ($tokens['access_token'] ?? '')); } catch (\Throwable) { // Access token tidak wajib dibaca aplikasi; cukup dikirim ke API yang membutuhkannya. } return ['claims' => $claims, 'access' => $access, 'id_token' => $tokens['id_token']]; } /** Alamat keluar dari sesi SSO X-Guard. id_token_hint membuktikan permintaan ini dari aplikasi Anda. */ public function logoutUrl(?string $idToken): string { $query = array_filter([ 'id_token_hint' => $idToken, 'post_logout_redirect_uri' => $this->config['post_logout_redirect_uri'] ?? null, ]); return $this->discovery()['end_session_endpoint'].'?'.http_build_query($query, '', '&', PHP_QUERY_RFC3986); } /** * Webhook X-Guard (panel Webhook di rincian aplikasi). Tanda tangan X-XGuard-Signature adalah * sha256=, dihitung atas body mentah. Kiriman yang lebih tua dari * $toleransiDetik ditolak, supaya rekaman lama tidak bisa diputar ulang. * * @return array{id: string, type: string, sub?: string, email?: string, access?: string, occurred_at: string} * * @throws SsoException secret kosong, timestamp di luar toleransi, tanda tangan salah, atau body rusak */ public static function verifyWebhook(string $secret, string $timestamp, string $signature, string $body, ?int $sekarang = null, int $toleransiDetik = 300): array { if ($secret === '') { throw new SsoException('Secret webhook belum dipasang (XGUARD_WEBHOOK_SECRET).'); } if (! ctype_digit($timestamp) || abs(($sekarang ?? time()) - (int) $timestamp) > $toleransiDetik) { throw new SsoException('Timestamp webhook di luar toleransi.'); } $harus = 'sha256='.hash_hmac('sha256', $timestamp.'.'.$body, $secret); if (! hash_equals($harus, trim($signature))) { throw new SsoException('Tanda tangan webhook salah.'); } $event = json_decode($body, true); if (! is_array($event) || empty($event['id']) || empty($event['type']) || (empty($event['sub']) && $event['type'] !== 'webhook.ping')) { throw new SsoException('Body webhook tidak valid.'); } return $event; } /** * Logout token dari back-channel logout (OIDC Back-Channel Logout 1.0 §2.6). X-Guard mengirimnya antarserver ke * "Alamat back-channel logout" aplikasi saat pengguna keluar dari sesi SSO, dari aplikasi mana pun. * * @return object klaim token; sid menunjuk sesi X-Guard yang berakhir */ public function verifyLogoutToken(string $jwt): object { $claims = $this->verify($jwt); $events = (array) ($claims->events ?? []); if (! array_key_exists('http://schemas.openid.net/event/backchannel-logout', $events)) { throw new SsoException('Bukan logout token (klaim events).'); } if (isset($claims->nonce)) { throw new SsoException('Logout token tidak boleh membawa nonce.'); } if (empty($claims->sid) && empty($claims->sub)) { throw new SsoException('Logout token tanpa sid dan sub.'); } return $claims; } /** Verifikasi tanda tangan dan isi token X-Guard (iss, aud, exp). */ private function verify(string $jwt): object { $parts = explode('.', $jwt); if (count($parts) !== 3) { throw new SsoException('Token bukan JWT.'); } JWT::$leeway = $this->config['leeway']; try { $kid = JWT::jsonDecode(JWT::urlsafeB64Decode($parts[0]))->kid ?? null; $keys = JWK::parseKeySet($this->jwks(false), 'RS256'); if ($kid !== null && ! isset($keys[$kid])) { // Kunci X-Guard dirotasi berkala: kid yang belum ada di cache → ambil JWKS terbaru. $keys = JWK::parseKeySet($this->jwks(true), 'RS256'); } $claims = JWT::decode($jwt, $keys); } catch (UnexpectedValueException|\DomainException $e) { throw new SsoException('Token X-Guard tidak sah: '.$e->getMessage(), 0, $e); } if (($claims->iss ?? null) !== $this->config['issuer']) { throw new SsoException('Token bukan dari X-Guard organisasi ini (iss).'); } $aud = (array) ($claims->aud ?? []); if (! in_array($this->config['client_id'], $aud, true)) { throw new SsoException('Token bukan untuk aplikasi ini (aud).'); } return $claims; } /** Kunci publik X-Guard (JWKS), disimpan di cache 10 menit. */ private function jwks(bool $fresh): array { $key = 'xguard:jwks:'.md5($this->config['issuer']); if ($fresh) { Cache::forget($key); } return Cache::remember($key, 600, fn () => Http::timeout(10)->acceptJson() ->get($this->discovery()['jwks_uri'])->throw()->json()); } private static function base64url(string $bytes): string { return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '='); } }