'/', 'domain' => '', 'secure' => true, 'httponly' => true, 'samesite' => 'Lax']); session_start(); } header('Cache-Control: no-store, private'); header('Referrer-Policy: no-referrer'); function oneFail(int $status, string $message): never { http_response_code($status); exit($message); // Fixed safe messages only; never output upstream responses or credentials. } function onePost(string $path, array $data): array { $client = getenv('ONE_CLIENT_ID'); $secret = getenv('ONE_CLIENT_SECRET'); if (! $client || ! $secret || ! in_array($path, ['/api/sso/token', '/api/sso/introspect'], true)) { throw new RuntimeException('SSO client is not configured'); } $ch = curl_init(ONE_ISSUER.$path); curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => http_build_query($data + ['client_id' => $client, 'client_secret' => $secret]), CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_TIMEOUT => 10, CURLOPT_FOLLOWLOCATION => false, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_HTTPHEADER => ['Accept: application/json']]); $raw = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($raw === false || $status !== 200) { throw new RuntimeException('SSO validation unavailable'); } try { $result = json_decode($raw, true, 32, JSON_THROW_ON_ERROR); } catch (JsonException) { throw new RuntimeException('Invalid SSO response'); } if (! is_array($result)) { throw new RuntimeException('Invalid SSO response'); } return $result; } function oneStart(): never { if (! getenv('ONE_CLIENT_ID') || ! getenv('ONE_CLIENT_SECRET')) { oneFail(503, 'SSO client is not configured'); } // One pending login per session. Use a bounded, expiring state map for concurrent tabs. $_SESSION['one_state'] = bin2hex(random_bytes(32)); $_SESSION['one_state_time'] = time(); header('Location: '.ONE_ISSUER.'/sso/authorize?'.http_build_query([ 'client_id' => getenv('ONE_CLIENT_ID'), 'state' => $_SESSION['one_state'], ])); exit; } function oneCallback(): never { $state = $_GET['state'] ?? ''; $expected = $_SESSION['one_state'] ?? ''; $created = $_SESSION['one_state_time'] ?? 0; unset($_SESSION['one_state'], $_SESSION['one_state_time']); if (! is_string($state) || ! is_string($expected) || $expected === '' || ! hash_equals($expected, $state) || ! is_int($created) || $created > time() || time() - $created > 300) { oneFail(403, 'Invalid login state. Restart from Tapee One.'); } $code = $_GET['code'] ?? ''; if (! is_string($code) || $code === '' || strlen($code) > 100) { oneFail(403, 'Invalid login code. Restart from Tapee One.'); } try { $result = onePost('/api/sso/token', ['code' => $code]); if (! is_string($result['access_token'] ?? null) || $result['access_token'] === '' || strlen($result['access_token']) > 100 || ($result['token_type'] ?? '') !== 'Bearer' || ! is_int($result['expires_in'] ?? null) || $result['expires_in'] < 1 || $result['expires_in'] > 3600) { throw new RuntimeException('Invalid token response'); } } catch (Throwable) { oneFail(503, 'Login could not be verified. Restart from Tapee One.'); } session_regenerate_id(true); $_SESSION['one_access_token'] = $result['access_token']; $identity = oneRequireAccess(); // Validate identity before application-specific local account mapping. // Resolve issuer+sub first. If absent, require $identity['email_verified'] === true, // then transactionally link exactly one active local account by normalized email. // Reject duplicate/conflicting mappings, audit the link, preserve local roles. // See guide section 7 for the complete mapping algorithm and unique constraints. header('Location: /'); // Fixed clean URL; never trust a return URL from the query string. exit; } function oneRequireAccess(): array { $token = $_SESSION['one_access_token'] ?? null; if (! is_string($token) || $token === '') { oneFail(401, 'Please open this application from Tapee One'); } try { $identity = onePost('/api/sso/introspect', ['token' => $token]); } catch (Throwable) { oneFail(503, 'Access verification unavailable'); } if (! is_bool($identity['active'] ?? null)) { oneFail(503, 'Invalid access verification response'); } if ($identity['active'] !== true) { unset($_SESSION['one_access_token']); oneFail(403, 'Access is no longer permitted'); } if (! is_string($identity['sub'] ?? null) || $identity['sub'] === '' || ! is_bool($identity['email_verified'] ?? null) || ! is_string($identity['name'] ?? null) || ! is_string($identity['email'] ?? null) || ! filter_var($identity['email'], FILTER_VALIDATE_EMAIL) || ! str_ends_with(strtolower($identity['email']), '@tapee.ac.th') || ! is_int($identity['program_id'] ?? null) || $identity['program_id'] < 1 || ! is_int($identity['exp'] ?? null) || $identity['exp'] <= time()) { unset($_SESSION['one_access_token']); oneFail(503, 'Invalid access verification response'); } return $identity; } // Registered callback: if (isset($_GET['code'])) oneCallback(); else oneStart(); // Every protected handler: $identity = oneRequireAccess(); then check local role/ownership/CSRF.