From 9ad0c2b76321eba2ae3fc151147b9e5eda4b3fc7 Mon Sep 17 00:00:00 2001 From: andre Date: Fri, 12 Jun 2026 22:17:40 +0100 Subject: [PATCH] SSO: redeem one-time ticket on return from the hub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fitme3070.pt is off the .andmore.pt cookie scope, so am_sso never arrives and login never provisioned the user. Redeem the ?sso_ticket=… the hub now appends to the return URL instead. - sso_redeem_ticket(): atomic single-use burn of sso_tickets in the central DB, bound to PLATFORM_SLUG, then provision the local row + FIT_SESS session. - Extract _sso_provision_local() shared with the cookie path. - views/auth/login.php redeems the ticket before the cookie fallback. Co-Authored-By: Claude Opus 4.8 --- config/sso.php | 74 +++++++++++++++++++++++++++++++++++++++----- views/auth/login.php | 13 ++++++-- 2 files changed, 76 insertions(+), 11 deletions(-) diff --git a/config/sso.php b/config/sso.php index 96e7e9f..426129b 100644 --- a/config/sso.php +++ b/config/sso.php @@ -87,7 +87,6 @@ function sso_resolve_session(PDO $pdo): void { if (!$ssoRow) return; $canonicalId = (int) $ssoRow['user_id']; - $email = strtolower(trim((string) $ssoRow['email'])); // If a local session is already set, decide whether to keep it or replace. if (!empty($_SESSION['user_id'])) { @@ -96,12 +95,70 @@ function sso_resolve_session(PDO $pdo): void { return; // Same person — keep the existing session. } // Either: (a) session is for an unrelated andmore_id (user switched - // identities at id.andmore.pt), or (b) session is for a local-only - // user that never got linked. In both cases the SSO cookie reflects - // the real intent — clear the stale identity bits and re-provision. + // identities at the hub), or (b) session is for a local-only user that + // never got linked. In both cases the SSO cookie reflects the real + // intent — clear the stale identity bits and re-provision. unset($_SESSION['user_id'], $_SESSION['user']); } + _sso_provision_local($pdo, $ssoRow); +} + +// 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. +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; + + 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; + + $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()); + return false; + } + + if (!$row || !$row['is_active']) 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); +} + +// Find-or-create the local users row for a central identity, sync name/phone, +// stamp last_login, and set the local PHP session. $ssoRow carries the central +// columns: user_id (canonical andmore id), email, name, phone. Returns true +// when a session was established. Shared by sso_resolve_session() (cookie path, +// same-site siblings) and sso_redeem_ticket() (ticket path, this install). +function _sso_provision_local(PDO $pdo, array $ssoRow): bool { + $canonicalId = (int) $ssoRow['user_id']; + $email = strtolower(trim((string) $ssoRow['email'])); + // ─── Find or create the local row ──────────────────────────────────── // Lookup order: andmore_id (fast, set on prior logins) → email (canonical // identity per the SSO-only model). The email branch claims any matching @@ -134,7 +191,7 @@ function sso_resolve_session(PDO $pdo): void { ]); } catch (PDOException $e) { error_log('[sso] provisioning insert failed: ' . $e->getMessage()); - return; + return false; } $stmt = $pdo->prepare("SELECT * FROM users WHERE andmore_id = ? LIMIT 1"); $stmt->execute([$canonicalId]); @@ -142,12 +199,12 @@ function sso_resolve_session(PDO $pdo): void { } } - if (!$user || !$user['is_active']) return; + if (!$user || !$user['is_active']) return false; // ─── Sync per-tenant row with central data ─────────────────────────── // Stamp the link if missing, refresh name/phone when the central record - // has a newer value. Local role is preserved — id.andmore.pt does not - // know about per-tenant roles. + // has a newer value. Local role is preserved — the hub does not know about + // per-tenant roles. $patch = []; if (empty($user['andmore_id']) || (int) $user['andmore_id'] !== $canonicalId) { $patch['andmore_id'] = $canonicalId; @@ -171,6 +228,7 @@ function sso_resolve_session(PDO $pdo): void { ->execute([$ip, $ua, $user['id']]); auth_set_session($user); + return true; } // ─── Upstream sync (federated mode) ────────────────────────────────────────── diff --git a/views/auth/login.php b/views/auth/login.php index ad11ed1..f6a7aa5 100644 --- a/views/auth/login.php +++ b/views/auth/login.php @@ -16,10 +16,17 @@ require __DIR__ . '/../../config/db.php'; require_once __DIR__ . '/../../config/functions.php'; -// Visitor may already have an am_sso cookie — resolve it instead of bouncing -// (saves a round-trip). +// Establish the session on return from the hub. fitme3070.pt is off the +// .andmore.pt cookie scope, so the real handoff is the one-time ?sso_ticket=… +// the hub appended to this return URL — redeem it. (sso_resolve_session() is +// kept as a fallback for the rare case a same-site am_sso cookie is present.) if (empty($_SESSION['user_id']) && identity_is_federated()) { - sso_resolve_session($pdo); + if (!empty($_GET['sso_ticket'])) { + sso_redeem_ticket($pdo, (string) $_GET['sso_ticket']); + } + if (empty($_SESSION['user_id'])) { + sso_resolve_session($pdo); + } } $next = $_GET['next'] ?? '';