SSO: redeem one-time ticket on return from the hub

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 <noreply@anthropic.com>
This commit is contained in:
andreandClaude Opus 4.8 committed 2026-06-12 22:17:40 +01:00
1 parent 3e3382df57
commit 9ad0c2b763
2 files changed
+76 -11

No files matched your search

+66 -8
View File
@@ -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) ──────────────────────────────────────────