prepare("SELECT banned FROM users WHERE id=:uid"); $stmt->execute([':uid' => $user['id']]); $row = $stmt->fetch(); $banned = !empty($row['banned']); if ($banned) { // Force logout banned user unset($_SESSION['oauth_user']); $user = null; } else { $stmt = db()->prepare("SELECT 1 FROM moderators WHERE user_id=:uid"); $stmt->execute([':uid' => $user['id']]); $is_mod = (bool)$stmt->fetchColumn(); } } json_out([ 'user' => $user, 'is_admin' => !empty($_SESSION['is_admin']), 'is_mod' => $is_mod, 'banned' => $banned, ]); } // ── Debug ───────────────────────────────────────────────────── if ($action === 'debug') { $states = db()->query("SELECT state, provider, created_at FROM oauth_state WHERE state != '__debug__' ORDER BY created_at DESC LIMIT 5")->fetchAll(); $debug_row = db()->prepare("SELECT verifier FROM oauth_state WHERE state='__debug__'")->execute() ? null : null; $stmt = db()->prepare("SELECT verifier FROM oauth_state WHERE state='__debug__'"); $stmt->execute(); $debug_row = $stmt->fetch(); $kick_debug = $debug_row ? json_decode($debug_row['verifier'], true) : null; json_out([ 'session_id' => session_id(), 'oauth_user' => $_SESSION['oauth_user'] ?? null, 'kick_debug' => $kick_debug, 'twitch_redirect' => TWITCH_OAUTH_REDIRECT_URI, 'kick_redirect' => defined('KICK_OAUTH_REDIRECT_URI') ? KICK_OAUTH_REDIRECT_URI : '', 'twitch_client_set' => !empty(TWITCH_OAUTH_CLIENT_ID), 'kick_client_set' => !empty(KICK_OAUTH_CLIENT_ID), 'https_detected' => is_https(), 'recent_db_states' => $states, ]); } // ── Logout ──────────────────────────────────────────────────── if ($action === 'logout') { unset($_SESSION['oauth_user']); header('Location: /'); exit; } // ── Twitch OAuth ────────────────────────────────────────────── if ($provider === 'twitch') { if (empty($code) && empty($error)) { $state = bin2hex(random_bytes(16)); $_SESSION['oauth_state'] = $state; $_SESSION['oauth_provider'] = 'twitch'; $params = http_build_query([ 'client_id' => TWITCH_OAUTH_CLIENT_ID, 'redirect_uri' => TWITCH_OAUTH_REDIRECT_URI, 'response_type' => 'code', 'scope' => 'user:read:email', 'state' => $state, 'force_verify' => 'false', ]); header('Location: https://id.twitch.tv/oauth2/authorize?' . $params); exit; } if (!empty($error)) { header('Location: /?auth_error=' . urlencode($error)); exit; } if (($_GET['state'] ?? '') !== ($_SESSION['oauth_state'] ?? '')) { header('Location: /?auth_error=invalid_state'); exit; } unset($_SESSION['oauth_state'], $_SESSION['oauth_provider']); $resp = do_post('https://id.twitch.tv/oauth2/token', [], http_build_query([ 'client_id' => TWITCH_OAUTH_CLIENT_ID, 'client_secret' => TWITCH_OAUTH_CLIENT_SECRET, 'code' => $code, 'grant_type' => 'authorization_code', 'redirect_uri' => TWITCH_OAUTH_REDIRECT_URI, ])); if ($resp['status'] !== 200) { header('Location: /?auth_error=twitch_token_failed_' . $resp['status']); exit; } $access_token = json_decode($resp['body'], true)['access_token'] ?? ''; if (empty($access_token)) { header('Location: /?auth_error=twitch_no_token'); exit; } $resp = do_get('https://api.twitch.tv/helix/users', [ 'Client-ID: ' . TWITCH_OAUTH_CLIENT_ID, 'Authorization: Bearer ' . $access_token, ]); if ($resp['status'] !== 200) { header('Location: /?auth_error=twitch_user_failed'); exit; } $tu = json_decode($resp['body'], true)['data'][0] ?? null; if (!$tu) { header('Location: /?auth_error=twitch_no_user'); exit; } $_SESSION['oauth_user'] = upsert_user('twitch', $tu['id'], $tu['login'], $tu['display_name'], $tu['profile_image_url'] ?? ''); header('Location: /'); exit; } // ── Kick OAuth (PKCE, state stored in DB) ──────────────────── if ($provider === 'kick') { $kick_id = defined('KICK_OAUTH_CLIENT_ID') ? KICK_OAUTH_CLIENT_ID : ''; $kick_secret = defined('KICK_OAUTH_CLIENT_SECRET') ? KICK_OAUTH_CLIENT_SECRET : ''; $kick_uri = defined('KICK_OAUTH_REDIRECT_URI') ? KICK_OAUTH_REDIRECT_URI : ''; if (empty($kick_id)) { header('Location: /?auth_error=kick_not_configured'); exit; } if (empty($code) && empty($error)) { $verifier = rtrim(strtr(base64_encode(random_bytes(48)), '+/', '-_'), '='); $challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='); $state = bin2hex(random_bytes(16)); // Store verifier in DB — session is unreliable across OAuth redirect db()->prepare("DELETE FROM oauth_state WHERE created_at < NOW() - INTERVAL '10 minutes'")->execute(); db()->prepare("INSERT INTO oauth_state (state, provider, verifier) VALUES (:s,'kick',:v) ON CONFLICT (state) DO UPDATE SET verifier=EXCLUDED.verifier") ->execute([':s' => $state, ':v' => $verifier]); $params = http_build_query([ 'client_id' => $kick_id, 'redirect_uri' => $kick_uri, 'response_type' => 'code', 'scope' => 'user:read channel:read', 'state' => $state, 'code_challenge' => $challenge, 'code_challenge_method' => 'S256', ]); header('Location: https://id.kick.com/oauth/authorize?' . $params); exit; } if (!empty($error)) { header('Location: /?auth_error=' . urlencode($error)); exit; } // Retrieve verifier from DB using state $stmt = db()->prepare("SELECT verifier FROM oauth_state WHERE state=:s AND provider='kick'"); $stmt->execute([':s' => $_GET['state'] ?? '']); $row = $stmt->fetch(); if (!$row) { header('Location: /?auth_error=kick_invalid_state'); exit; } $verifier = $row['verifier']; db()->prepare("DELETE FROM oauth_state WHERE state=:s")->execute([':s' => $_GET['state']]); $fields = [ 'grant_type' => 'authorization_code', 'client_id' => $kick_id, 'redirect_uri' => $kick_uri, 'code' => $code, 'code_verifier' => $verifier, ]; if (!empty($kick_secret)) $fields['client_secret'] = $kick_secret; $resp_token = do_post('https://id.kick.com/oauth/token', [], http_build_query($fields)); if ($resp_token['status'] !== 200) { $err = json_decode($resp_token['body'], true); $msg = $err['error'] ?? $err['message'] ?? $err['error_description'] ?? ''; if (empty($msg)) { $_SESSION['kick_debug'] = ['status' => $resp_token['status'], 'body' => substr($resp_token['body'], 0, 500), 'fields' => $fields]; header('Location: /?auth_error=kick_token_' . $resp_token['status'] . '_see_debug'); } else { header('Location: /?auth_error=' . urlencode('kick_token_' . $resp_token['status'] . '_' . $msg)); } exit; } $token_data = json_decode($resp_token['body'], true); $access_token = $token_data['access_token'] ?? ''; $resp_token_raw = $resp_token['body']; if (empty($access_token)) { header('Location: /?auth_error=kick_no_token'); exit; } $auth_header = ['Authorization: Bearer ' . $access_token, 'Accept: application/json']; // Correct endpoint per Kick docs — GET /public/v1/users returns current user $resp_user = do_get('https://api.kick.com/public/v1/users', $auth_header); $debug = json_encode([ 'introspect_scope' => json_decode($resp_token_raw, true)['scope'] ?? '', 'users_endpoint' => ['s' => $resp_user['status'], 'b' => substr($resp_user['body'], 0, 500)], ]); db()->prepare("INSERT INTO oauth_state (state, provider, verifier) VALUES ('__debug__','kick_debug',:d) ON CONFLICT (state) DO UPDATE SET verifier=EXCLUDED.verifier, created_at=NOW()") ->execute([':d' => $debug]); if ($resp_user['status'] !== 200) { header('Location: /?auth_error=kick_user_failed_' . $resp_user['status'] . '_see_debug'); exit; } $ku = json_decode($resp_user['body'], true) ?? []; // Handle potential data wrapper if (isset($ku['data'])) $ku = $ku['data']; if (isset($ku[0])) $ku = $ku[0]; $ku_id = $ku['id'] ?? $ku['user_id'] ?? null; $ku_login = $ku['username'] ?? $ku['slug'] ?? $ku['name'] ?? ''; $ku_display = $ku['name'] ?? $ku['username'] ?? $ku_login; $ku_avatar = $ku['profile_picture'] ?? $ku['profile_pic'] ?? $ku['picture'] ?? ''; if (empty($ku_id)) { header('Location: /?auth_error=kick_no_user'); exit; } $_SESSION['oauth_user'] = upsert_user('kick', (string)$ku_id, $ku_login, $ku_display, $ku_avatar); header('Location: /'); exit; } json_error('Not found', 404); // ── Helpers ─────────────────────────────────────────────────── function do_post(string $url, array $extra_headers, string $body): array { $default_headers = empty($body) ? ['Accept: application/json'] : ['Content-Type: application/x-www-form-urlencoded', 'Accept: application/json']; $headers = array_merge($default_headers, $extra_headers); $ch = curl_init($url); $opts = [CURLOPT_RETURNTRANSFER=>true, CURLOPT_POST=>true, CURLOPT_HTTPHEADER=>$headers, CURLOPT_TIMEOUT=>10]; if (!empty($body)) $opts[CURLOPT_POSTFIELDS] = $body; curl_setopt_array($ch, $opts); $resp = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); return ['status' => $status, 'body' => (string)$resp]; } function do_get(string $url, array $extra_headers = []): array { $headers = array_merge(['Accept: application/json'], $extra_headers); $ch = curl_init($url); curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_HTTPHEADER=>$headers, CURLOPT_TIMEOUT=>10]); $resp = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); return ['status' => $status, 'body' => (string)$resp]; } function upsert_user(string $provider, string $pid, string $login, string $display, string $avatar): array { $stmt = db()->prepare(" INSERT INTO users (provider, provider_id, login, display_name, avatar, last_seen) VALUES (:p,:pid,:l,:d,:a,NOW()) ON CONFLICT (provider, provider_id) DO UPDATE SET login=EXCLUDED.login, display_name=EXCLUDED.display_name, avatar=EXCLUDED.avatar, last_seen=NOW() RETURNING id, provider, login, display_name, avatar "); $stmt->execute([':p'=>$provider,':pid'=>$pid,':l'=>$login,':d'=>$display,':a'=>$avatar]); return $stmt->fetch(); } function is_https(): bool { return (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') || (isset($_SERVER['SERVER_PORT']) && $_SERVER['SERVER_PORT'] == 443); }