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
// 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
// cookie lookup always comes up empty. Instead the hub appends ?sso_ticket=… to
// the return URL after authenticating; we burn it here against the central
// 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.
// cookie lookup always comes up empty. The hub appends ?sso_ticket=… to the
// return URL after authenticating.
//
// 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 {
if (!identity_is_federated()) return false;
if (strlen($ticket) !== 64 || !ctype_xdigit($ticket)) return false;
$ssoPdo = _sso_open_pdo();
if (!$ssoPdo) return false;
$secret = (string) env('HUB_CONTENT_SECRET', '');
if ($secret === '') { error_log('[sso] ticket redeem: HUB_CONTENT_SECRET not set'); return false; }
try {
// Burn first: the UPDATE only affects a row when the ticket is unused,
// unexpired and bound to this platform — so a replay or a ticket minted
// for a sibling platform changes nothing and we bail.
$burn = $ssoPdo->prepare("
UPDATE sso_tickets SET used_at = NOW()
WHERE ticket = ? AND platform = ? AND used_at IS NULL AND expires_at > NOW()
");
$burn->execute([$ticket, PLATFORM_SLUG]);
if ($burn->rowCount() !== 1) return false;
$url = rtrim(ANDMORE_ID_URL, '/') . '/api/sso_redeem.php';
$path = (string) (parse_url($url, PHP_URL_PATH) ?? '/');
$date = gmdate('c');
$token = hash_hmac('sha256', $date . $path . $ticket, $secret);
$body = http_build_query(['ticket' => $ticket]);
$headers = [
'X-Hub-Platform: ' . PLATFORM_SLUG,
'X-Hub-Date: ' . $date,
'X-Hub-Token: ' . $token,
'Accept: application/json',
'Content-Type: application/x-www-form-urlencoded',
];
$stmt = $ssoPdo->prepare("
SELECT t.user_id, u.email, u.name, u.phone, u.is_active
FROM sso_tickets t
JOIN users u ON u.id = t.user_id
WHERE t.ticket = ?
LIMIT 1
");
$stmt->execute([$ticket]);
$row = $stmt->fetch();
} catch (PDOException $e) {
error_log('[sso] ticket redeem failed: ' . $e->getMessage());
// cURL first (same transport the notify/content clients use); stream
// fallback for installs without the extension.
$raw = false;
if (function_exists('curl_init')) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_TIMEOUT => 12,
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;
}
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.
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,