Files
script.service.ultimate/lib/streaming_providers/providers/joyn/auth.py
T
2026-10-11 12:23:19 +02:00

752 lines
31 KiB
Python

# streaming_providers/providers/joyn/auth.py
# -*- coding: utf-8 -*-
import base64
import json
import re
import time
import uuid
import requests
from dataclasses import dataclass, field
from typing import Any, Dict, Optional
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
from ...base.auth.base_auth import BaseAuthToken, TokenAuthLevel
from ...base.auth.base_oauth2_auth import BaseOAuth2Authenticator, WafBlockedException
from ...base.auth.credentials import ClientCredentials, UserPasswordCredentials
from ...base.models.proxy_models import ProxyConfig
from ...base.utils.logger import logger
from .config import JoynConfig
from .models import JoynAuthError, JoynMfaRequiredException
from .constants import (
COUNTRY_TENANT_MAPPING,
DEFAULT_COUNTRY,
DEFAULT_PLATFORM,
DEVICE_IDS,
JOYN_AUTH_ENDPOINTS,
JOYN_AUTH_HEADERS_BASE,
JOYN_OAUTH_SCOPE,
JOYN_SEC_CH_UA,
JOYN_SEC_CH_UA_PLATFORM,
JOYN_USER_AGENT,
SUPPORTED_COUNTRIES,
)
# NOTE: JoynMfaRequiredException is imported from .models — it must NOT be
# redefined here. The migration brief moves it into the models hierarchy so
# it inherits AuthError, which the settings UI relies on. If you find
# yourself adding a `class JoynMfaRequiredException` below, stop: that would
# shadow the import and break the typed-error contract.
@dataclass
class JoynCredentials(ClientCredentials):
"""Joyn-specific credentials for client credentials flow (anonymous auth)"""
client_name: str = DEFAULT_PLATFORM
country: str = DEFAULT_COUNTRY
distribution_tenant: Optional[str] = field(default=None)
def __post_init__(self):
if not self.client_id:
self.client_id = DEVICE_IDS.get(self.client_name, DEVICE_IDS[DEFAULT_PLATFORM])
if not self.distribution_tenant and self.country in COUNTRY_TENANT_MAPPING:
self.distribution_tenant = COUNTRY_TENANT_MAPPING[self.country]
def validate(self) -> bool:
return bool(self.client_id and self.client_name and self.country in SUPPORTED_COUNTRIES)
def to_auth_payload(self) -> Dict[str, Any]:
return {
"client_id": self.client_id,
"client_name": self.client_name,
"anon_device_id": str(uuid.uuid4()),
}
@property
def credential_type(self) -> str:
return "joyn_client_credentials"
@dataclass
class JoynAuthToken(BaseAuthToken):
"""Joyn-specific authentication token"""
refresh_token: Optional[str] = field(default="")
def to_dict(self) -> Dict[str, Any]:
return {
"access_token": self.access_token,
"refresh_token": self.refresh_token or "",
"token_type": self.token_type,
"expires_in": self.expires_in,
"issued_at": self.issued_at,
}
def get_jwt_claims(self) -> Optional[Dict[str, Any]]:
"""Extract JWT claims from access token for classification"""
try:
if not self.access_token:
return None
parts = self.access_token.split(".")
if len(parts) != 3:
return None
payload_b64 = parts[1]
padding = len(payload_b64) % 4
if padding:
payload_b64 += "=" * (4 - padding)
payload_json = base64.b64decode(payload_b64).decode("utf-8")
return json.loads(payload_json)
except Exception as e:
logger.debug(f"Failed to extract JWT claims: {e}")
return None
class JoynAuthenticator(BaseOAuth2Authenticator):
"""
Joyn authenticator based on actual network traffic logs.
Config: uses the shared JoynConfig from config.py. The provider creates it
once and passes it in via `config=`; a private one is built only when the
authenticator is used standalone. Do NOT define a local config class here
and do NOT rebuild a second JoynConfig next to the provider's one — that is
how the distribution_tenant drifted before.
"""
def __init__(
self,
country: str = DEFAULT_COUNTRY,
platform: str = DEFAULT_PLATFORM,
settings_manager=None,
credentials=None,
config_dir: Optional[str] = None,
http_manager=None,
proxy_config: Optional[ProxyConfig] = None,
config: Optional[JoynConfig] = None,
):
if country not in SUPPORTED_COUNTRIES:
raise ValueError(f"Unsupported country: {country}")
if http_manager is None:
raise ValueError("http_manager is required for JoynAuthenticator")
self.country = country
self.platform = platform
# Cache for flow parameters
self._sso_endpoints_cache = None
self._sso_endpoints_timestamp = None
self._sso_cache_ttl = 3600
self._cmp_uc_id = None
self._cmp_uc_instance = None
self._auth_base_path = None
self._web_login_url = None
self._extracted_client_id = None
super().__init__(
provider_name="joyn",
settings_manager=settings_manager,
credentials=credentials,
country=country,
config_dir=config_dir,
enable_kodi_integration=True,
http_manager=http_manager,
proxy_config=proxy_config,
)
# CRITICAL ORDERING: BaseOAuth2Authenticator.__init__ sets self._config = None.
# Anything assigned to self._config (or self.config, which writes _config)
# BEFORE super().__init__ is silently discarded. Assign the shared JoynConfig
# AFTER, and set it through the base's `config` property so the base's own
# methods (which read self.config.user_agent, self.config.base_website,
# self.config.get_base_headers, ...) find it too.
self._joyn_config = config if config is not None else JoynConfig(
country=country, platform=platform
)
self.config = self._joyn_config # sets self._config via the property setter
self.distribution_tenant = self._joyn_config.distribution_tenant
# Load or generate persistent device ID
self._device_id = self._load_or_generate_device_id()
self._use_pkce = True
self._enable_oidc_discovery = False
if settings_manager is not None:
settings_manager.register_provider(
"joyn",
supports_countries=True,
available_countries=SUPPORTED_COUNTRIES,
)
# The web client ID (still a fixed platform-constant; see the comment
# in the original about "the CORRECT web client ID").
self._client_id = DEVICE_IDS.get(self.platform, DEVICE_IDS[DEFAULT_PLATFORM])
logger.info(f"Using Joyn client_id: {self._client_id}")
if self.credentials is None:
logger.info(f"No credentials for joyn/{self.country}, using anonymous fallback")
self.credentials = self.get_fallback_credentials()
# ------------------------------------------------------------------
# Token accessor used by JoynSession
# ------------------------------------------------------------------
# The session needs the BaseAuthToken *object* (to check is_expired),
# while get_bearer_token returns a *string*. This property bridges the
# two without forcing the session to re-implement the token cache.
#
# Check whether BaseOAuth2Authenticator already provides this — if it
# does, delete this property and rely on the base. Grep:
# grep -n "def current_token\|self\._current_token" \
# base/auth/base_oauth2_auth.py base/auth/base_auth.py
@property
def current_token(self):
return self._current_token
def _load_or_generate_device_id(self) -> str:
"""Load existing device ID from settings or generate new one"""
if self.settings_manager and hasattr(self.settings_manager, 'get_setting'):
try:
device_id = self.settings_manager.get_setting("joyn_device_id")
if device_id:
logger.debug(f"Loaded existing device_id: {device_id}")
return device_id
except Exception as e:
logger.debug(f"Could not load device_id: {e}")
new_device_id = str(uuid.uuid4())
if self.settings_manager and hasattr(self.settings_manager, 'set_setting'):
try:
self.settings_manager.set_setting("joyn_device_id", new_device_id)
except Exception as e:
logger.debug(f"Could not save device_id: {e}")
logger.debug(f"Generated new device_id: {new_device_id}")
return new_device_id
@property
def oauth_client_id(self) -> str:
return self._client_id
@property
def oauth_scope(self) -> str:
return JOYN_OAUTH_SCOPE
@property
def oauth_redirect_uri(self) -> str:
return self._config.oauth_redirect_uri()
def _discover_sso_endpoints(self) -> Dict[str, str]:
"""Discover Joyn's SSO endpoints and keep the server-issued web-login URL and client_id as-is."""
if self._sso_endpoints_cache and self._sso_endpoints_timestamp:
if (time.time() - self._sso_endpoints_timestamp) < self._sso_cache_ttl:
return self._sso_endpoints_cache
try:
url = f"https://auth.joyn.de/sso/endpoints?client_id={self._device_id}&client_name={self.platform}"
headers = self._get_joyn_auth_headers()
response = self.http_manager.get(
url,
operation="sso_discovery",
headers=headers,
timeout=self._config.timeout
)
response.raise_for_status()
endpoints = response.json()
auth_endpoint_full = endpoints.get("web-login", "")
self._web_login_url = auth_endpoint_full
parsed_auth = urlparse(auth_endpoint_full)
self._auth_base_path = urlunparse((
parsed_auth.scheme,
parsed_auth.netloc,
parsed_auth.path,
"", "", ""
))
params = parse_qs(parsed_auth.query)
self._extracted_client_id = params.get("client_id", [None])[0]
self._cmp_uc_id = params.get("cmpUcId", [None])[0]
self._cmp_uc_instance = params.get("cmpUcInstance", [None])[0]
self._sso_endpoints_cache = {
"authorization_base_path": self._auth_base_path,
"web_login_url": self._web_login_url,
"client_id": self._extracted_client_id,
"token_endpoint": endpoints.get("redeem-token", "https://auth.joyn.de/auth/7pass/token"),
}
self._sso_endpoints_timestamp = time.time()
logger.debug(f"Discovered web-login URL: {self._web_login_url}")
logger.debug(f"Extracted client_id: {self._extracted_client_id}")
return self._sso_endpoints_cache
except Exception as e:
logger.warning(f"Failed to discover SSO endpoints: {e}")
self._auth_base_path = "https://auth.7pass.de/authz-srv/authz"
return {
"authorization_base_path": self._auth_base_path,
"token_endpoint": "https://auth.joyn.de/auth/7pass/token",
}
@property
def oauth_authorize_endpoint(self) -> str:
"""Get clean authorization base path"""
self._discover_sso_endpoints()
return self._auth_base_path or "https://auth.7pass.de/authz-srv/authz"
@property
def oauth_token_endpoint(self) -> str:
endpoints = self._discover_sso_endpoints()
return endpoints.get("token_endpoint", "https://auth.joyn.de/auth/7pass/token")
def _get_joyn_auth_headers(self) -> Dict[str, str]:
headers = JOYN_AUTH_HEADERS_BASE.copy()
headers.update({
"Origin": self._config.website(),
"joyn-country": self.country.upper(),
"joyn-distribution-tenant": self._config.distribution_tenant,
"joyn-platform": self.platform,
"joyn-request-id": str(uuid.uuid4()),
"Content-Type": "application/json",
})
return headers
def _get_auth_headers(self) -> Dict[str, str]:
return self._get_joyn_auth_headers()
def _should_use_json_for_token_exchange(self, **kwargs) -> bool:
return True
def _build_token_exchange_payload(
self, authorization_code: str, code_verifier: str, state: str = None, **kwargs
) -> Dict[str, Any]:
payload = super()._build_token_exchange_payload(
authorization_code=authorization_code,
code_verifier=code_verifier,
state=state,
**kwargs
)
cd1 = kwargs.get('cd1')
if cd1 is None:
cd1 = self._device_id
if cd1:
payload["tracking_id"] = cd1
payload["tracking_name"] = self.platform
return payload
def should_upgrade_token(self, token) -> bool:
if getattr(self, "_joyn_upgrade_attempted", False):
return False
from ...base.auth.credentials import UserPasswordCredentials
if not isinstance(self.credentials, UserPasswordCredentials):
return False
self._joyn_upgrade_attempted = True
return True
def _get_token_exchange_endpoint(self, **kwargs) -> str:
return self.oauth_token_endpoint
def _get_token_exchange_headers(self, **kwargs) -> Dict[str, str]:
headers = super()._get_token_exchange_headers(**kwargs)
joyn_headers = self._get_joyn_auth_headers()
for key, value in joyn_headers.items():
if key not in headers:
headers[key] = value
return headers
def _refresh_oauth_token(self) -> Optional[BaseAuthToken]:
if not self._current_token or not self._current_token.refresh_token:
return None
try:
if hasattr(self._current_token, 'get_jwt_claims'):
claims = self._current_token.get_jwt_claims()
if claims and claims.get("jIdC", "").startswith("JNAA-"):
logger.debug("Anonymous token cannot be refreshed")
return None
payload = {
"refresh_token": self._current_token.refresh_token,
"grant_type": self._current_token.token_type,
"client_id": self._device_id,
"client_name": self.platform,
}
response = self.http_manager.post(
JOYN_AUTH_ENDPOINTS["REFRESH"],
operation="auth",
headers=self._get_joyn_auth_headers(),
json_data=payload,
timeout=self._config.timeout,
)
self._check_oauth_error_response(response)
token_data = response.json()
refreshed_token = self._create_token_from_response(token_data)
logger.info("Joyn token refresh successful")
return refreshed_token
except Exception as e:
logger.warning(f"Token refresh failed: {e}")
return None
def _sec_fetch_site_for(self, url: str) -> str:
def registrable(netloc: str) -> str:
return ".".join(netloc.split(".")[-2:])
target = registrable(urlparse(url).netloc)
origin = registrable(urlparse(self._config.website()).netloc)
return "same-site" if target == origin else "cross-site"
def _perform_oauth_authorization_code_flow(self, username: str, password: str) -> Dict[str, Any]:
"""
Complete Joyn login flow matching the exact sequence observed from
working traffic. Unchanged from v1 (see the git history for the full
commentary on each step); the only change in the v2 migration is that
JoynMfaRequiredException now comes from models.py and inherits
AuthError.
"""
try:
logger.debug("Starting Joyn login flow")
self._discover_sso_endpoints()
if not self._web_login_url:
raise Exception("Failed to get web-login URL from SSO discovery")
client_id = self._extracted_client_id or self._device_id
cd1 = self._device_id
session = self._create_oauth_session()
session.headers.clear()
def _request(method, url, **kwargs):
headers = kwargs.pop("headers", {}).copy()
if "auth.7pass.de" in url:
clean_headers = {
k: v for k, v in headers.items()
if not k.lower().startswith('joyn-')
}
else:
clean_headers = dict(headers)
clean_headers.setdefault("User-Agent", JOYN_USER_AGENT)
clean_headers.setdefault("Accept", "*/*")
clean_headers.setdefault("Accept-Language", "de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7")
clean_headers.setdefault("Accept-Encoding", "gzip, deflate, br")
clean_headers.setdefault("Cache-Control", "no-cache")
clean_headers.setdefault("Pragma", "no-cache")
clean_headers.setdefault("sec-ch-ua-mobile", "?0")
clean_headers.setdefault("sec-ch-ua", JOYN_SEC_CH_UA)
clean_headers.setdefault("sec-ch-ua-platform", JOYN_SEC_CH_UA_PLATFORM)
clean_headers.setdefault("Sec-Fetch-Site", self._sec_fetch_site_for(url))
clean_headers.setdefault("Sec-Fetch-Mode", "cors")
clean_headers.setdefault("Sec-Fetch-Dest", "empty")
content_type = kwargs.pop("content_type", None)
if content_type:
clean_headers["Content-Type"] = content_type
allow_redirects = kwargs.pop("allow_redirects", True)
timeout = self._config.timeout
if method.upper() == "GET":
return session.get(url, headers=clean_headers, timeout=timeout,
allow_redirects=allow_redirects, **kwargs)
else:
return session.post(url, headers=clean_headers, timeout=timeout,
allow_redirects=allow_redirects, **kwargs)
def _check_cf(response):
if "Just a moment" in response.text or "challenge-platform" in response.text:
raise WafBlockedException("Cloudflare managed challenge detected")
def _raise_if_cf_error(e):
resp = getattr(e, "response", None)
raw = getattr(resp, "text", str(e))
if "Just a moment" in raw or "challenge-platform" in raw or "captcha" in raw.lower():
raise WafBlockedException(f"Cloudflare/CAPTCHA block: {e}")
if resp is not None and resp.status_code in (403, 429):
raise WafBlockedException(f"Joyn login blocked by WAF ({resp.status_code}): {e}")
# 1. GET the literal web-login URL
try:
response = _request("GET", self._web_login_url, allow_redirects=True)
except WafBlockedException:
raise
except Exception as e:
_raise_if_cf_error(e)
raise
_check_cf(response)
final_url = response.url
if "error.html" in final_url or "error_code" in final_url:
error_match = re.search(r'error_code=(\d+)', final_url)
error_code = error_match.group(1) if error_match else "unknown"
raise Exception(f"Authorization failed: error_code={error_code}")
parsed_url = urlparse(final_url)
query_params = parse_qs(parsed_url.query)
request_id = query_params.get("requestId", [None])[0]
if not request_id:
match = re.search(r'requestId["\']?\s*[=:]\s*["\']([^"\']+)', response.text)
if match:
request_id = match.group(1)
if not request_id:
raise Exception("Could not extract request_id from response")
logger.debug(f"Extracted request_id: {request_id}")
# 2-4. Non-fatal probes
try:
_request("GET", f"https://auth.7pass.de/registration-setup-srv/public/list?acceptlanguage=undefined&requestId={request_id}")
except Exception as e:
logger.debug(f"registration-setup failed (non-fatal): {e}")
try:
_request("POST", f"https://auth.7pass.de/users-srv/user/checkexists/{request_id}",
json={"email": username, "requestId": request_id},
content_type="application/json")
except Exception as e:
logger.debug(f"checkexists failed (non-fatal): {e}")
try:
_request("POST", "https://auth.7pass.de/verification-srv/v2/setup/public/configured/list",
json={"email": username, "request_id": request_id},
content_type="application/json")
except Exception as e:
logger.debug(f"verification-srv failed (non-fatal): {e}")
# 5. Login
login_response = _request(
"POST", "https://auth.7pass.de/login-srv/login",
data=urlencode({"username": username, "password": password, "requestId": request_id}).encode(),
content_type="application/x-www-form-urlencoded",
allow_redirects=True,
)
_check_cf(login_response)
final_url = login_response.url
parsed = urlparse(final_url)
params = parse_qs(parsed.query)
# 5a. MFA
if "signin.7pass.de" in final_url and "/mfa" in final_url:
logger.error("Joyn account has two-factor authentication enabled.")
raise JoynMfaRequiredException(
"Two-factor authentication is enabled on this Joyn account. "
"Please disable MFA in your Joyn account settings "
"(https://www.joyn.de/account) to use this provider."
)
# 6. Consent
if params.get("code") is None:
sub = params.get("sub", [None])[0]
track_id = params.get("track_id", [None])[0]
if sub and track_id:
_request("POST", "https://auth.7pass.de/consent-management-srv/consent/scope/accept",
json={"sub": sub, "client_id": client_id, "scopes": [{"offline_access": "denied"}]},
content_type="application/json")
try:
continue_response = _request(
"POST", f"https://auth.7pass.de/login-srv/precheck/continue/{track_id}",
data=b"", content_type="application/x-www-form-urlencoded",
allow_redirects=True)
except WafBlockedException:
raise
except Exception as e:
_raise_if_cf_error(e)
raise
final_url = continue_response.url
parsed = urlparse(final_url)
params = parse_qs(parsed.query)
auth_code = params.get("code", [None])[0]
if not auth_code:
# The login POST did not end in an authorization code, there was
# no consent step to continue, no MFA redirect and no WAF page
# (all handled above): 7pass rejected the credentials (or the flow
# changed). Retrying the same input cannot help, and repeated
# failed logins lock the account — so this is a PERMANENT, typed
# AuthError; JoynSession will not retry until reset().
raise JoynAuthError(
"Joyn login did not return an authorization code "
"(credentials rejected, or the 7pass flow changed)"
)
# 7. Redeem
cd1_value = params.get("cd1", [None])[0] or cd1
redeem_data = {
"client_id": client_id,
"code": auth_code,
"code_verifier": "",
"redirect_uri": self.oauth_redirect_uri,
"tracking_id": cd1_value,
"tracking_name": self.platform,
}
try:
redeem_response = _request(
"POST", self.oauth_token_endpoint,
json=redeem_data, content_type="application/json",
headers=self._get_joyn_auth_headers(),
allow_redirects=False)
except WafBlockedException:
raise
except Exception as e:
_raise_if_cf_error(e)
raise
_check_cf(redeem_response)
token_data = redeem_response.json()
logger.info("Joyn login flow successful")
return token_data
except WafBlockedException:
raise
except (JoynMfaRequiredException, JoynAuthError):
raise
except Exception as e:
logger.error(f"Joyn login flow failed: {e}")
raise
def authenticate_with_fallback(self, username: str, password: str) -> Dict[str, Any]:
try:
return self._perform_oauth_authorization_code_flow(username, password)
except (JoynMfaRequiredException, JoynAuthError):
raise # permanent and user-actionable: never fall back to anonymous
except WafBlockedException as e:
logger.warning(f"{self.provider_name}: WAF block detected ({e}), trying remote login")
try:
return self._perform_remote_login_flow()
except (WafBlockedException, ConnectionError, TimeoutError) as remote_err:
logger.warning(f"{self.provider_name}: Remote login failed ({remote_err}), falling back to client credentials")
return self._perform_oauth_client_credentials_flow()
except (ConnectionError, TimeoutError, requests.exceptions.HTTPError) as e:
logger.warning(f"{self.provider_name}: Network login failed ({e}), falling back to client credentials")
return self._perform_oauth_client_credentials_flow()
def _perform_oauth_client_credentials_flow(self) -> Dict[str, Any]:
try:
logger.info("Starting client credentials flow")
payload = {
"client_id": self._device_id,
"client_name": self.platform,
"anon_device_id": self._device_id
}
anonymous_token_url = "https://auth.joyn.de/auth/anonymous"
headers = {
"Content-Type": "application/json",
"User-Agent": JOYN_USER_AGENT,
"Accept": "application/json",
"Origin": self._config.website(),
}
logger.debug(f"Anonymous token request to {anonymous_token_url} with client_id: {payload['client_id']}")
response = self.http_manager.post(
anonymous_token_url, operation="auth", headers=headers,
json_data=payload, timeout=self._config.timeout)
self._check_oauth_error_response(response)
response.raise_for_status()
token_data = response.json()
logger.info("Client credentials flow successful")
return token_data
except Exception as e:
logger.error(f"Client credentials flow failed: {e}")
raise
def get_fallback_credentials(self) -> JoynCredentials:
return JoynCredentials(
client_id=self._client_id,
client_secret="",
country=self.country,
)
def _build_auth_payload(self) -> Dict[str, Any]:
if not self.credentials:
raise Exception("No credentials available")
return self.credentials.to_auth_payload()
def _create_token_from_response(self, response_data: Dict[str, Any]) -> BaseAuthToken:
# Rely on BaseAuthToken.is_expired (300 s buffer): one buffer, one place.
# v1 subtracted a second buffer here. Removed because no token is embedded
# in a DRMConfig or in CDN headers for Joyn (drm_license_headers() and
# cdn_headers() carry no bearer; the licence URL is self-signed), so there
# is no playback-session-long token lifetime to protect. RECORD this in
# MIGRATION_BRIEF.md §3 ("Token buffer") and confirm with a long
# playback across the expiry on a device (README §9).
expires_in = response_data.get("expires_in", 86400)
token = JoynAuthToken(
access_token=response_data["access_token"],
refresh_token=response_data.get("refresh_token", ""),
token_type=response_data.get("token_type", "Bearer"),
expires_in=expires_in,
issued_at=response_data.get("issued_at", time.time()),
)
token.auth_level = self._classify_token(token)
return token
def _classify_token(self, token: BaseAuthToken) -> TokenAuthLevel:
try:
if not token or not token.access_token:
return TokenAuthLevel.UNKNOWN
claims = token.get_jwt_claims() if hasattr(token, "get_jwt_claims") else None
if not claims:
return TokenAuthLevel.UNKNOWN
jidc = claims.get("jIdC", "")
if jidc.startswith("JNAA-"):
return TokenAuthLevel.CLIENT_CREDENTIALS
elif jidc.startswith("JNDE-"):
return TokenAuthLevel.USER_AUTHENTICATED
if "social_id" in claims:
return TokenAuthLevel.USER_AUTHENTICATED
return TokenAuthLevel.UNKNOWN
except Exception as e:
logger.error(f"Error classifying token: {e}")
return TokenAuthLevel.UNKNOWN
def _perform_authentication(self) -> BaseAuthToken:
if isinstance(self.credentials, UserPasswordCredentials):
token_data = self.authenticate_with_fallback(
self.credentials.username, self.credentials.password
)
else:
token_data = self._perform_oauth_client_credentials_flow()
return self._create_token_from_response(token_data)
def get_bearer_token(self, force_refresh: bool = False, force_upgrade: bool = False) -> str:
return super().get_bearer_token(force_refresh=force_refresh, force_upgrade=force_upgrade)
def is_authenticated(self) -> bool:
return self._current_token is not None and not self._current_token.is_expired
def invalidate_token(self) -> None:
self._current_token = None
self._joyn_upgrade_attempted = False
try:
self.settings_manager.clear_token(self.provider_name)
except (AttributeError, KeyError, IOError, OSError):
pass
def debug_token_classification(self) -> Dict[str, Any]:
if not self._current_token:
return {"error": "No current token"}
claims = self._current_token.get_jwt_claims() if hasattr(self._current_token, "get_jwt_claims") else {}
return {
"token_type": type(self._current_token).__name__,
"auth_level": self._current_token.auth_level.value,
"is_expired": self._current_token.is_expired,
"has_refresh": bool(self._current_token.refresh_token),
"jwt_claims_available": bool(claims),
"key_claims": {
"jIdC": claims.get("jIdC", "MISSING"),
"cId": claims.get("cId", "MISSING"),
"social_id": "PRESENT" if "social_id" in claims else "MISSING",
} if claims else {},
}