Add LinkedIn OAuth flow
- linkedin_auth API: generates auth URL with CSRF state stored in session - linkedin_callback: exchanges code for tokens, writes to project's config.json - index.php: route /linkedin/callback before auth check - Connect button wired up in JS; reads LINKEDIN_CLIENT_ID/SECRET from server env Server setup required: SetEnv LINKEDIN_CLIENT_ID ... SetEnv LINKEDIN_CLIENT_SECRET ... Redirect URI to register in LinkedIn app: https://<host>/linkedin/callback Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
3d7b620b6c
commit
158a5b0775
5 changed files with 126 additions and 3 deletions
|
|
@ -40,10 +40,9 @@
|
|||
<div class="small fw-semibold" id="liConnectionLabel">Checking…</div>
|
||||
<div class="text-muted small" id="liTokenExpiry"></div>
|
||||
</div>
|
||||
<a href="#" class="btn btn-sm btn-outline-primary ms-auto disabled" id="liConnectBtn">
|
||||
<button class="btn btn-sm btn-outline-primary ms-auto" id="liConnectBtn">
|
||||
<i class="bi bi-box-arrow-in-right me-1"></i>Connect LinkedIn
|
||||
<span class="badge bg-secondary ms-1">coming soon</span>
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
27
web/api/linkedin_auth.php
Normal file
27
web/api/linkedin_auth.php
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$pid = (int)($_GET['project_id'] ?? 0);
|
||||
if (!$pid) { http_response_code(400); echo json_encode(['error' => 'project_id required']); exit; }
|
||||
|
||||
$stmt = $db->prepare('SELECT * FROM projects WHERE id = ? AND is_active = 1');
|
||||
$stmt->execute([$pid]);
|
||||
if (!$stmt->fetch()) { http_response_code(404); echo json_encode(['error' => 'Project not found']); exit; }
|
||||
|
||||
$client_id = getenv('LINKEDIN_CLIENT_ID');
|
||||
if (!$client_id) { echo json_encode(['error' => 'LINKEDIN_CLIENT_ID not set in server environment']); exit; }
|
||||
|
||||
$state = bin2hex(random_bytes(16));
|
||||
$_SESSION['linkedin_oauth'] = ['state' => $state, 'project_id' => $pid];
|
||||
|
||||
$redirect_uri = (isset($_SERVER['HTTPS']) ? 'https' : 'http') . '://' . $_SERVER['HTTP_HOST'] . '/linkedin/callback';
|
||||
|
||||
$url = 'https://www.linkedin.com/oauth/v2/authorization?' . http_build_query([
|
||||
'response_type' => 'code',
|
||||
'client_id' => $client_id,
|
||||
'redirect_uri' => $redirect_uri,
|
||||
'state' => $state,
|
||||
'scope' => 'w_organization_social openid profile',
|
||||
]);
|
||||
|
||||
echo json_encode(['url' => $url]);
|
||||
|
|
@ -2364,6 +2364,7 @@ if (addScanPathForm) {
|
|||
botconfig_mastodon_remove: 'mastodon removed',
|
||||
botconfig_linkedin_page_add: 'linkedin page added', botconfig_linkedin_page_save: 'linkedin page updated',
|
||||
botconfig_linkedin_page_remove: 'linkedin page removed',
|
||||
linkedin_oauth_connect: 'linkedin connected',
|
||||
file_write: 'file edit', file_delete: 'file delete', file_upload: 'upload',
|
||||
post_create: 'post created', post_delete: 'post deleted',
|
||||
post_publish: 'post published', post_duplicate: 'post duplicated',
|
||||
|
|
@ -2399,6 +2400,7 @@ if (addScanPathForm) {
|
|||
botconfig_rss_add: 'bi-rss', botconfig_rss_save: 'bi-rss', botconfig_rss_remove: 'bi-rss',
|
||||
botconfig_mastodon_add: 'bi-mastodon', botconfig_mastodon_save: 'bi-mastodon', botconfig_mastodon_remove: 'bi-mastodon',
|
||||
botconfig_linkedin_page_add: 'bi-linkedin', botconfig_linkedin_page_save: 'bi-linkedin', botconfig_linkedin_page_remove: 'bi-linkedin',
|
||||
linkedin_oauth_connect: 'bi-linkedin',
|
||||
file_write: 'bi-pencil', file_delete: 'bi-trash', file_upload: 'bi-cloud-upload',
|
||||
post_create: 'bi-file-earmark-plus', post_delete: 'bi-file-earmark-x',
|
||||
post_publish: 'bi-send', post_duplicate: 'bi-files',
|
||||
|
|
@ -3386,6 +3388,13 @@ window.addEventListener('DOMContentLoaded', () => {
|
|||
expiry.textContent = li.token_expiry ? 'Expires: ' + li.token_expiry : '';
|
||||
}
|
||||
|
||||
document.getElementById('liConnectBtn').addEventListener('click', async () => {
|
||||
const r = await fetch('/api/linkedin_auth?project_id=' + pid);
|
||||
const d = await r.json();
|
||||
if (d.error) { showError(d.error); return; }
|
||||
window.location.href = d.url;
|
||||
});
|
||||
|
||||
function renderLinkedinPages(pages) {
|
||||
const list = document.getElementById('linkedinPagesList');
|
||||
const names = Object.keys(pages);
|
||||
|
|
|
|||
|
|
@ -15,6 +15,13 @@ if ($uri === '/api/auth') {
|
|||
exit;
|
||||
}
|
||||
|
||||
// LinkedIn OAuth callback — session required but no full auth check
|
||||
// (LinkedIn redirects back here; user's browser session is active)
|
||||
if ($uri === '/linkedin/callback') {
|
||||
include ROOT . '/web/linkedin_callback.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
// All other routes require login
|
||||
Auth::requireLogin();
|
||||
ProjectTypes::load();
|
||||
|
|
|
|||
81
web/linkedin_callback.php
Normal file
81
web/linkedin_callback.php
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
<?php
|
||||
// LinkedIn OAuth callback — validates state, exchanges code for tokens,
|
||||
// writes them to the discord-bot project's data/config.json.
|
||||
|
||||
if ($_GET['state'] !== ($_SESSION['linkedin_oauth']['state'] ?? '')) {
|
||||
http_response_code(400);
|
||||
echo 'Invalid OAuth state. <a href="/">Go back</a>';
|
||||
exit;
|
||||
}
|
||||
|
||||
$pid = (int)($_SESSION['linkedin_oauth']['project_id'] ?? 0);
|
||||
unset($_SESSION['linkedin_oauth']);
|
||||
|
||||
if (isset($_GET['error'])) {
|
||||
$msg = htmlspecialchars($_GET['error_description'] ?? $_GET['error']);
|
||||
echo "LinkedIn denied access: $msg. <a href=\"/project/$pid?tab=botconfig\">Go back</a>";
|
||||
exit;
|
||||
}
|
||||
|
||||
$code = $_GET['code'] ?? '';
|
||||
if (!$code) { echo 'Missing code. <a href="/">Go back</a>'; exit; }
|
||||
|
||||
$client_id = getenv('LINKEDIN_CLIENT_ID');
|
||||
$client_secret = getenv('LINKEDIN_CLIENT_SECRET');
|
||||
$redirect_uri = (isset($_SERVER['HTTPS']) ? 'https' : 'http') . '://' . $_SERVER['HTTP_HOST'] . '/linkedin/callback';
|
||||
|
||||
// Exchange code for tokens
|
||||
$ch = curl_init('https://www.linkedin.com/oauth/v2/accessToken');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => http_build_query([
|
||||
'grant_type' => 'authorization_code',
|
||||
'code' => $code,
|
||||
'redirect_uri' => $redirect_uri,
|
||||
'client_id' => $client_id,
|
||||
'client_secret' => $client_secret,
|
||||
]),
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded'],
|
||||
]);
|
||||
$body = curl_exec($ch);
|
||||
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
$data = json_decode($body, true);
|
||||
if ($status !== 200 || empty($data['access_token'])) {
|
||||
$err = htmlspecialchars($data['error_description'] ?? $data['error'] ?? 'Unknown error');
|
||||
echo "LinkedIn token exchange failed: $err. <a href=\"/project/$pid?tab=botconfig\">Go back</a>";
|
||||
exit;
|
||||
}
|
||||
|
||||
// Get the project's config.json path
|
||||
$stmt = $db->prepare('SELECT path FROM projects WHERE id = ? AND is_active = 1');
|
||||
$stmt->execute([$pid]);
|
||||
$project = $stmt->fetch();
|
||||
if (!$project) { echo 'Project not found. <a href="/">Go back</a>'; exit; }
|
||||
|
||||
$config_path = realpath($project['path']) . '/data/config.json';
|
||||
|
||||
if (!file_exists($config_path)) {
|
||||
echo "config.json not found at $config_path. <a href=\"/project/$pid?tab=botconfig\">Go back</a>";
|
||||
exit;
|
||||
}
|
||||
|
||||
$config = json_decode(file_get_contents($config_path), true);
|
||||
if (!is_array($config)) { echo 'Could not parse config.json. <a href="/">Go back</a>'; exit; }
|
||||
|
||||
if (!isset($config['linkedin'])) $config['linkedin'] = [];
|
||||
$config['linkedin']['access_token'] = $data['access_token'];
|
||||
$config['linkedin']['refresh_token'] = $data['refresh_token'] ?? null;
|
||||
$expires_in = $data['expires_in'] ?? 5184000;
|
||||
$config['linkedin']['token_expiry'] = date('c', time() + $expires_in);
|
||||
|
||||
$tmp = $config_path . '.tmp';
|
||||
file_put_contents($tmp, json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
rename($tmp, $config_path);
|
||||
|
||||
Audit::log($db, 'linkedin_oauth_connect', $pid);
|
||||
|
||||
header('Location: /project/' . $pid . '?tab=botconfig');
|
||||
exit;
|
||||
Loading…
Add table
Add a link
Reference in a new issue