From 420fe4c30dc314b09c3e7190e15fc88c4340beb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85smund=20V=C3=A5ge=20Fannemel?= <34712686+asmfstatoil@users.noreply.github.com> Date: Mon, 27 Apr 2026 09:07:49 +0200 Subject: [PATCH 1/2] feat: app registration authentication for equnor pi webapi --- examples/demo.py | 36 ++++++++++++++++++++++++++++++++++++ tagreader/web_handlers.py | 34 ++++++++++++++++++++++++++++------ 2 files changed, 64 insertions(+), 6 deletions(-) create mode 100644 examples/demo.py diff --git a/examples/demo.py b/examples/demo.py new file mode 100644 index 00000000..9d48b599 --- /dev/null +++ b/examples/demo.py @@ -0,0 +1,36 @@ +import tagreader +from tagreader.utils import IMSType +from tagreader.web_handlers import get_auth_aspen, get_auth_pi, get_url_aspen + +use_internal = False + + +print( + tagreader.list_sources( + imstype=IMSType.ASPENONE, + url=get_url_aspen(use_internal=use_internal), + auth=get_auth_aspen(use_internal=use_internal), + ) +) + +c = tagreader.IMSClient( + "GRA", + handler_options={"max_rows": 1e7}, + auth=get_auth_aspen(use_internal=use_internal), + url=get_url_aspen(use_internal=use_internal), +) +t = c.search("*PDIC*") +d = c.read(tags=t[0][0], start_time="2024-Jan-01", end_time="2024-Jan-02") + + +print( + tagreader.list_sources( + imstype=IMSType.PIWEBAPI, auth=get_auth_pi(use_internal=use_internal) + ) +) +c = tagreader.IMSClient("JSV") +t4 = c.search("*PDIC*") +d = c.read(tags=t4[0][0], start_time="2024-Jan-02", end_time="2024-Jan-02") + + +pass diff --git a/tagreader/web_handlers.py b/tagreader/web_handlers.py index 8f4de601..bb906713 100644 --- a/tagreader/web_handlers.py +++ b/tagreader/web_handlers.py @@ -14,6 +14,7 @@ import requests import urllib3 from Crypto.Hash import MD4 as _MD4 +from msal_bearer import BearerAuth from requests_kerberos import OPTIONAL, HTTPKerberosAuth from urllib3.exceptions import InsecureRequestWarning @@ -55,20 +56,28 @@ def get_verify_ssl() -> Union[bool, str]: return "/etc/ssl/certs/ca-bundle.trust.crt" -def get_auth_pi() -> HTTPKerberosAuth: - return HTTPKerberosAuth(mutual_authentication=OPTIONAL) +def get_auth_pi(use_internal: bool = True) -> Union[HTTPKerberosAuth, BearerAuth]: + if use_internal: + return HTTPKerberosAuth(mutual_authentication=OPTIONAL) + + tenant_id = "3aa4a235-b6e2-48d5-9195-7fcf05b459b0" + client_id = "98fe146b-2687-4db9-9c84-45f4cd9063af" + + scopes = ["https://piwebapi.equinor.com//user_impersonation"] + + return BearerAuth.get_auth( + tenantID=tenant_id, clientID=client_id, scopes=scopes, verbose=True + ) def get_url_pi() -> str: return r"https://piwebapi.equinor.com/piwebapi" -def get_auth_aspen(use_internal: bool = True): +def get_auth_aspen(use_internal: bool = True) -> Union[HTTPKerberosAuth, BearerAuth]: if use_internal: return HTTPKerberosAuth(mutual_authentication=OPTIONAL) - from msal_bearer import BearerAuth - tenantID = "3aa4a235-b6e2-48d5-9195-7fcf05b459b0" clientID = "7adaaa99-897f-428c-8a5f-4053db565b32" scopes = [ @@ -115,6 +124,8 @@ def list_aspenone_sources( except JSONDecodeError as e: logger.error(f"Could not decode JSON response: {e}") + return [] + def list_piwebapi_sources( url: Optional[str] = None, @@ -134,7 +145,14 @@ def list_piwebapi_sources( urllib3.disable_warnings(InsecureRequestWarning) url_ = urljoin(url, "dataservers") - res = requests.get(url_, auth=auth, verify=verify_ssl, timeout=300) + res = requests.get( + url_, + auth=auth, + verify=verify_ssl, + timeout=300, + headers={"Accept": "application/json"}, + allow_redirects=False, + ) res.raise_for_status() try: @@ -143,6 +161,8 @@ def list_piwebapi_sources( except JSONDecodeError as e: logger.error(f"Could not decode JSON response: {e}") + return [] + def get_piwebapi_source_to_webid_dict( url: Optional[str] = None, @@ -170,6 +190,8 @@ def get_piwebapi_source_to_webid_dict( except JSONDecodeError as e: logger.error(f"Could not decode JSON response: {e}") + return [] + class BaseHandlerWeb(ABC): def __init__( From 30e6f500a87efa28188a176491dd3ba2ce95464d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85smund=20V=C3=A5ge=20Fannemel?= <34712686+asmfstatoil@users.noreply.github.com> Date: Tue, 12 May 2026 15:56:15 +0200 Subject: [PATCH 2/2] wow --- examples/demo_f5.py | 3 + pyproject.toml | 4 +- tagreader/web_handlers.py | 162 +++++++++++++++++++++++++++++++++----- 3 files changed, 149 insertions(+), 20 deletions(-) create mode 100644 examples/demo_f5.py diff --git a/examples/demo_f5.py b/examples/demo_f5.py new file mode 100644 index 00000000..06754393 --- /dev/null +++ b/examples/demo_f5.py @@ -0,0 +1,3 @@ +from tagreader.web_handlers import list_piwebapi_sources + +print(list_piwebapi_sources()) diff --git a/pyproject.toml b/pyproject.toml index bcc59b8c..73933a97 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,9 @@ dependencies = [ "pycryptodome>=3.20.0", "requests-ntlm>=1.1,<=2.0", "platformdirs>=4.3.7", - "urllib3>=2.5.0" # To avoid security issue https://www.cve.org/CVERecord?id=CVE-2025-50182 + "urllib3>=2.5.0", # To avoid security issue https://www.cve.org/CVERecord?id=CVE-2025-50182 + "playwright (>=1.59.0,<2.0.0)", + "browser-cookie3 (>=0.20.1,<0.21.0)" ] [tool.poetry.group.test.dependencies] diff --git a/tagreader/web_handlers.py b/tagreader/web_handlers.py index bb906713..b6a46ee3 100644 --- a/tagreader/web_handlers.py +++ b/tagreader/web_handlers.py @@ -5,16 +5,22 @@ from abc import ABC, abstractmethod from datetime import datetime, timedelta from hashlib import new as hashlib_new_method +from http.cookiejar import Cookie, CookieJar from json.decoder import JSONDecodeError +from pathlib import Path from typing import Any, Dict, List, Optional, Tuple, Union +import browser_cookie3 import numpy as np import pandas as pd import pytz import requests import urllib3 from Crypto.Hash import MD4 as _MD4 -from msal_bearer import BearerAuth +from msal_bearer import BearerAuth, get_user_name +from playwright.sync_api import BrowserContext +from playwright.sync_api import TimeoutError as PlaywrightTimeoutError +from playwright.sync_api import sync_playwright from requests_kerberos import OPTIONAL, HTTPKerberosAuth from urllib3.exceptions import InsecureRequestWarning @@ -56,18 +62,128 @@ def get_verify_ssl() -> Union[bool, str]: return "/etc/ssl/certs/ca-bundle.trust.crt" +def f5_check_browser_cookie(): + cookies = browser_cookie3.edge(domain_name=".equinor.com") + if len(cookies) == 0: + raise ConnectionError( + "No cookies found for .piwebapi.equinor.com. Please log in to the F5 VPN using Microsoft Edge and try again." + ) + for cookie in cookies: + if "piweb" in cookie.domain: # or "pivision" in cookie.domain: + print(f"Found cookie for {cookie.domain}, looks good!") + + if "MRHSession" in cookie.name: + print("Found MRHSession cookie, looks good!") + return cookie + # return cookie + return None + + +def browser_context_to_cookiejar( + context: BrowserContext, domain_filter: Optional[str] = None +): + """Convert Playwright browser context cookies to a Requests cookie jar.""" + cookie_jar = CookieJar() + for cookie in context.cookies(): + domain = cookie.get("domain") + if domain_filter and (not domain or domain_filter not in domain): + continue + + cookie_jar.set_cookie( + Cookie( + name=cookie["name"], + value=cookie["value"], + domain=domain, + path=cookie.get("path", "/"), + secure=cookie.get("secure", False), + expires=cookie.get("expires"), + rest={"HttpOnly": cookie.get("httpOnly", False)}, + ) + ) + return cookie_jar + + +def transfer_browser_context_to_session( + cookie_jar: Union[CookieJar, BrowserContext], + session: Optional[requests.Session] = None, + domain_filter: Optional[str] = None, +) -> requests.Session: + """Attach Playwright context cookies to a requests session.""" + if session is None: + session = requests.Session() + + if isinstance(cookie_jar, BrowserContext): + session.cookies.update(browser_context_to_cookiejar(cookie_jar, domain_filter)) + + if isinstance(cookie_jar, CookieJar): + session.cookies.update(cookie_jar) + + if isinstance(cookie_jar, list): + for x in cookie_jar: + if isinstance(x, Cookie): + session.cookies.update(x) + elif isinstance(x, dict) and "name" in x and "value" in x: + session.cookies.set( + name=x["name"], + value=x["value"], + domain=x.get("domain", None), + path=x.get("path", "/"), + secure=x.get("secure", False), + expires=x.get("expires", None), + rest={"HttpOnly": x.get("httpOnly", False)}, + ) + + return session + + +def ensure_f5_authenticated_context(): + STATE_FILE = Path(f"f5_{get_user_name()}_piwebapi_session.json") + + AUTH_TEST_URL = "https://piwebapi.equinor.com/piwebapi/system" + + with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=False, channel="msedge") + + context = browser.new_context( + storage_state=str(STATE_FILE) if STATE_FILE.exists() else None + ) + + page = context.new_page() + page.set_default_navigation_timeout(180_000) + + page.goto( + AUTH_TEST_URL, + wait_until="domcontentloaded", + timeout=180_000, + ) + + if any( + x in page.url.casefold() + for x in ["oauth", "login", "signin", "microsoftonline"] + ): + print("Complete login in the browser window...") + + try: + page.wait_for_url("**/piwebapi/**", timeout=300_000) + except PlaywrightTimeoutError: + input("Press Enter if login is complete...") + + context.storage_state(path=str(STATE_FILE)) + return context.cookies() + + raise ValueError("Unexpected error in F5 authentication flow") + + def get_auth_pi(use_internal: bool = True) -> Union[HTTPKerberosAuth, BearerAuth]: if use_internal: return HTTPKerberosAuth(mutual_authentication=OPTIONAL) - tenant_id = "3aa4a235-b6e2-48d5-9195-7fcf05b459b0" - client_id = "98fe146b-2687-4db9-9c84-45f4cd9063af" + cookie = f5_check_browser_cookie() + if cookie is not None: + print("Using cookie-based authentication for PI Web API") + return cookie - scopes = ["https://piwebapi.equinor.com//user_impersonation"] - - return BearerAuth.get_auth( - tenantID=tenant_id, clientID=client_id, scopes=scopes, verbose=True - ) + return ensure_f5_authenticated_context() def get_url_pi() -> str: @@ -136,7 +252,7 @@ def list_piwebapi_sources( url = get_url_pi() if auth is None: - auth = get_auth_pi() + auth = get_auth_pi(use_internal=False) if verify_ssl is None: verify_ssl = get_verify_ssl() @@ -145,14 +261,22 @@ def list_piwebapi_sources( urllib3.disable_warnings(InsecureRequestWarning) url_ = urljoin(url, "dataservers") - res = requests.get( - url_, - auth=auth, - verify=verify_ssl, - timeout=300, - headers={"Accept": "application/json"}, - allow_redirects=False, - ) + if ( + isinstance(auth, Cookie) + or isinstance(auth, list) + and all(isinstance(a, Cookie) or isinstance(a, dict) for a in auth) + ): + session = transfer_browser_context_to_session(cookie_jar=auth) + res = session.get(url_, verify=verify_ssl, timeout=300) + else: + res = requests.get( + url_, + auth=auth, + verify=verify_ssl, + timeout=300, + headers={"Accept": "application/json"}, + allow_redirects=False, + ) res.raise_for_status() try: @@ -459,7 +583,7 @@ def _get_maps(self, tagname: str): return ret def _get_default_mapname(self, tagname: str): - (tagname, _) = self.split_tagmap(tagname) + tagname, _ = self.split_tagmap(tagname) all_maps = self._get_maps(tagname) for k, v in all_maps.items(): if v: @@ -1016,7 +1140,7 @@ def read_tag( if not web_id: return pd.DataFrame() - (url, params) = self.generate_read_query( + url, params = self.generate_read_query( tag=web_id, start=start, end=end,