277 lines
12 KiB
PHP
277 lines
12 KiB
PHP
<?php
|
|
// ============================================================
|
|
// api/oauth.php — Twitch + Kick OAuth
|
|
// ============================================================
|
|
|
|
require_once __DIR__ . '/db.php';
|
|
|
|
cors();
|
|
start_session();
|
|
|
|
$provider = $_GET['provider'] ?? '';
|
|
$action = $_GET['action'] ?? '';
|
|
$code = $_GET['code'] ?? '';
|
|
$error = $_GET['error'] ?? '';
|
|
|
|
// ── Current user ─────────────────────────────────────────────
|
|
if ($action === 'me') {
|
|
start_session();
|
|
$user = $_SESSION['oauth_user'] ?? null;
|
|
$is_mod = false;
|
|
$banned = false;
|
|
|
|
if ($user) {
|
|
$stmt = db()->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);
|
|
}
|