SSO: redeem ticket over HTTPS instead of the central DB

This install is on external hosting and can't reach the central DB's private
LAN address (192.168.1.3), so the direct-DB burn always failed with
ECONNREFUSED. Redeem via the hub's api/sso_redeem.php over HTTPS (HMAC with
HUB_CONTENT_SECRET, ticket folded into the signature) and provision the
returned user into the local DB.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
andreandClaude Opus 4.8 committed 2026-06-12 23:55:13 +01:00
1 parent 9ad0c2b763
commit 7885b0b6ac
1 file changed
+63 -29
+63 -29
View File
@@ -107,47 +107,81 @@ function sso_resolve_session(PDO $pdo): void {
// Cross-domain handoff: redeem a one-time ticket minted by the hub. This is the // Cross-domain handoff: redeem a one-time ticket minted by the hub. This is the
// real login path for this install, because fitme3070.pt is NOT under // real login path for this install, because fitme3070.pt is NOT under
// .andmore.pt — the am_sso cookie never reaches it, so sso_resolve_session()'s // .andmore.pt — the am_sso cookie never reaches it, so sso_resolve_session()'s
// cookie lookup always comes up empty. Instead the hub appends ?sso_ticket=… to // cookie lookup always comes up empty. The hub appends ?sso_ticket=… to the
// the return URL after authenticating; we burn it here against the central // return URL after authenticating.
// sso_tickets table (atomic single-use, bound to this PLATFORM_SLUG, ~60s TTL) //
// and provision the local row. Returns true when a session was established. // We redeem over HTTPS, NOT the direct central DB: this install lives on
// external hosting and can't reach the central DB's private LAN address. The
// hub's api/sso_redeem.php burns the ticket against its local DB (single-use,
// bound to PLATFORM_SLUG, ~60s TTL) and returns the central user, which we
// provision into the LOCAL DB. Auth reuses the content-pull HMAC scheme with
// the ticket folded in. Returns true when a session was established.
function sso_redeem_ticket(PDO $pdo, string $ticket): bool { function sso_redeem_ticket(PDO $pdo, string $ticket): bool {
if (!identity_is_federated()) return false; if (!identity_is_federated()) return false;
if (strlen($ticket) !== 64 || !ctype_xdigit($ticket)) return false; if (strlen($ticket) !== 64 || !ctype_xdigit($ticket)) return false;
$ssoPdo = _sso_open_pdo(); $secret = (string) env('HUB_CONTENT_SECRET', '');
if (!$ssoPdo) return false; if ($secret === '') { error_log('[sso] ticket redeem: HUB_CONTENT_SECRET not set'); return false; }
try { $url = rtrim(ANDMORE_ID_URL, '/') . '/api/sso_redeem.php';
// Burn first: the UPDATE only affects a row when the ticket is unused, $path = (string) (parse_url($url, PHP_URL_PATH) ?? '/');
// unexpired and bound to this platform — so a replay or a ticket minted $date = gmdate('c');
// for a sibling platform changes nothing and we bail. $token = hash_hmac('sha256', $date . $path . $ticket, $secret);
$burn = $ssoPdo->prepare(" $body = http_build_query(['ticket' => $ticket]);
UPDATE sso_tickets SET used_at = NOW() $headers = [
WHERE ticket = ? AND platform = ? AND used_at IS NULL AND expires_at > NOW() 'X-Hub-Platform: ' . PLATFORM_SLUG,
"); 'X-Hub-Date: ' . $date,
$burn->execute([$ticket, PLATFORM_SLUG]); 'X-Hub-Token: ' . $token,
if ($burn->rowCount() !== 1) return false; 'Accept: application/json',
'Content-Type: application/x-www-form-urlencoded',
];
$stmt = $ssoPdo->prepare(" // cURL first (same transport the notify/content clients use); stream
SELECT t.user_id, u.email, u.name, u.phone, u.is_active // fallback for installs without the extension.
FROM sso_tickets t $raw = false;
JOIN users u ON u.id = t.user_id if (function_exists('curl_init')) {
WHERE t.ticket = ? $ch = curl_init($url);
LIMIT 1 curl_setopt_array($ch, [
"); CURLOPT_RETURNTRANSFER => true,
$stmt->execute([$ticket]); CURLOPT_POST => true,
$row = $stmt->fetch(); CURLOPT_POSTFIELDS => $body,
} catch (PDOException $e) { CURLOPT_TIMEOUT => 12,
error_log('[sso] ticket redeem failed: ' . $e->getMessage()); CURLOPT_HTTPHEADER => $headers,
CURLOPT_USERAGENT => 'fitme3070-SSO/1.0',
]);
$raw = curl_exec($ch);
curl_close($ch);
}
if ($raw === false) {
$ctx = stream_context_create(['http' => [
'method' => 'POST',
'timeout' => 12,
'header' => implode("\r\n", $headers),
'content' => $body,
'ignore_errors' => true,
]]);
$raw = @file_get_contents($url, false, $ctx);
}
if ($raw === false) { error_log('[sso] ticket redeem: no response from hub'); return false; }
$data = json_decode((string) $raw, true);
if (!is_array($data) || empty($data['ok']) || empty($data['user'])) {
error_log('[sso] ticket redeem rejected: ' . substr((string) $raw, 0, 200));
return false; return false;
} }
if (!$row || !$row['is_active']) return false; $u = $data['user'];
$ssoRow = [
'user_id' => (int) ($u['user_id'] ?? 0),
'email' => (string) ($u['email'] ?? ''),
'name' => (string) ($u['name'] ?? ''),
'phone' => $u['phone'] ?? null,
];
if ($ssoRow['user_id'] <= 0 || $ssoRow['email'] === '') return false;
// Explicit fresh-login intent — drop any stale local identity, then provision. // Explicit fresh-login intent — drop any stale local identity, then provision.
unset($_SESSION['user_id'], $_SESSION['user']); unset($_SESSION['user_id'], $_SESSION['user']);
return _sso_provision_local($pdo, $row); return _sso_provision_local($pdo, $ssoRow);
} }
// Find-or-create the local users row for a central identity, sync name/phone, // Find-or-create the local users row for a central identity, sync name/phone,