55 Commits
Author SHA1 Message Date
herel 19e3f751ec fix(frontend): Sanitize DOM rendering to prevent XSS via innerHTML
Replace unsafe innerHTML template literals with document.createElement()
and textContent for all user-conrolled data fields in index.html.

Event cards and modal details previously interpolated API responses
(event.name, event.title, player.fname, staff.role, etc.) directly into
innerHTML without escaping, allowing stored XSS if upstream MyIce data
is malicious (CWE-79 / vuln-0002).

All dynamic text now flows through textContent or createTextNode(),
while static HTML structures (SVG icons, strong labels) remain safe
hardcoded innerHTML assignments.

Refs vuln-0002
2026-06-29 20:05:45 +02:00
herel afcd230c3a fix(api): Send PKCE code_verifier in token exchange and secure OIDC cookies
The /exchange-token endpoint was not sending the code_verifier to the
token endpoint, rendering PKCE protection ineffective. Add the verifier
to the POST body so the IdP can validate the authorization code exchange.

Also add secure=True and samesite='Lax' to all OIDC cookies on /login
to improve cookie security per the recommended remediation.

Refs vuln-0004
2026-06-29 19:49:07 +02:00
herel 4593f9e8fb fix: use tailwindcss cli 2026-06-29 18:02:10 +02:00
herel 40c3131b90 fix: better subgroup 2026-06-29 17:35:48 +02:00
herel c3098cb415 fix: proper jwt verification 2026-06-29 17:35:36 +02:00
herel 8f9aec7631 fix: IDC State/Nonce Not Validated 2026-06-29 16:53:14 +02:00
herel bf7f7273e9 fix: add timeout for request 2026-06-29 16:52:35 +02:00
herel 071324d199 fix: Weak JWT Validation 2026-06-29 16:48:02 +02:00
herel 51ee0b6b32 fix: No static key fallback; reject non-OIDC tokens 2026-06-29 16:43:09 +02:00
herel 9c99787b1f fix: remove hard coded old secret 2026-06-29 16:42:51 +02:00
herel 8bb89eabdc fix: Permissive CORS Configuration 2026-06-29 16:40:36 +02:00
herel eca5eacd70 fix: Missing Authentication Checks 2026-06-29 16:37:39 +02:00
herel 5e17fd27d6 fix(security): Authentication Bypass via Broken JWT Validation in AuthHeaders.validate_oidc_token 2026-06-29 16:33:21 +02:00
herel 9dcf4f6fd3 chore: add some debug 2026-06-29 16:30:35 +02:00
herel ac8e252a2b chore: update categories 2026-06-29 16:28:15 +02:00
herel 1863f5586b fix: prevent OS Command Injection 2026-06-29 16:19:33 +02:00
herel c83bb2af9b fix(schedule): Use midnight as schedule start date
Change date_start from current time to today at midnight so the
schedule fetch query properly covers the full day.
2026-06-05 15:12:33 +02:00
herel 4c90716355 chore: update Dockerfile 2025-11-11 11:06:40 +01:00
herel a3360f3a1b feat: add ESC key support to close event details modal and bump version to v0.6.0 2025-11-11 08:51:58 +01:00
herel b74c820387 docs: improve AGENTS.md with better build/lint/test commands and code style guidelines 2025-11-11 08:46:13 +01:00
herel b5cc0f4888 chore(docker): use docker cache mount for pip install step 2025-10-15 16:03:01 +02:00
herel e85aaf143e feat: redesign UI with Tailwind CSS and improve event display 2025-10-15 15:59:57 +02:00
herel c21afdebc0 chore: use python image 3.13-slim instead of 3.13-alpine 2025-10-01 16:22:48 +02:00
herel 11d9aa0290 feat: slim down Docker image size by 8x using Alpine Linux base and multi-stage build 2025-09-30 09:22:26 +02:00
herel 33d3dee358 chore: update fastapi and dependencies to latest versions 2025-09-30 08:37:56 +02:00
herel 8ae1c33b3a chore: migrate to python 3.13 and update dependencies
Migrate from Python 3.11 to 3.13 with updated dependencies. Switch from PyPDF2 to pypdf library for better PDF processing. Add new U14 age groups and extract-pdf utility script.
2025-09-29 23:05:48 +02:00
herel ce42f489bf refactor: migrate to distroless multi-stage Docker build 2025-09-29 22:07:11 +02:00
herel e7615de98b feat: display staff information and improve empty state handling in game details modal 2025-09-29 20:17:23 +02:00
herel 394d71f59c doc: fix markdownlint issues in AGENTS.md 2025-09-23 09:33:44 +02:00
herel b016d58d84 chore: bump version to v0.5.7 2025-08-20 11:10:20 +02:00
herel 6232e91925 feat: layout filters horizontally 2025-08-20 11:10:06 +02:00
herel 7ce4fbd756 chore: bump version to v0.5.6 2025-08-20 10:17:08 +02:00
herel bb62acfc7f feat: add secondary agegroup selector for subgroup filtering
- Added subgroup dropdown selector to filter events by team within agegroup
- Implemented logic to populate subgroup options based on selected agegroup
- Updated event filtering to support both agegroup and subgroup criteria
- Added event listeners for real-time filtering when selectors change
2025-08-20 10:17:00 +02:00
herel 5f6ae79bf0 chore: bump version to v0.5.5 2025-08-19 19:22:21 +02:00
herel 697788c20f feat: auto-load events on login and improve player display in event details
- Automatically load events when user logs in
- Rename updateAccountOptions to updateAccountOptionsAndLoadEvents
- Add auto-fetch of events after account selection
- Enhance event details modal with:
  * Player count summary
  * Position breakdown
  * Players sorted by position first, then by number
  * Position displayed in player list
2025-08-19 19:21:50 +02:00
herel 5c5828cfc1 chore: add event title 2025-08-19 19:12:45 +02:00
herel 0a88217443 chore: bump version to v0.5.4 2025-08-19 19:06:47 +02:00
herel 2d783778a7 feat: update agegroup filter to show only agegroup and filter accordingly 2025-08-19 19:06:24 +02:00
herel a3d4114044 chore: bump version to v0.5.3 2025-08-19 18:50:12 +02:00
herel cec54a45d7 fix: improve UI layout and add loading indicator for events 2025-08-19 18:48:56 +02:00
herel 3efa7101e1 fix: finalize UI improvements and ensure all functionality works
Final adjustments to UI layout and positioning. Ensured all JavaScript functions are properly connected and working. Verified event loading functionality is fully restored.
2025-08-19 16:12:51 +02:00
herel 861ff0650f fix: restore event loading functionality and improve UI layout
Fixed incomplete fetchEvents function by adding missing data processing logic. Restored updateAgeGroupOptions, displayEvents, and fetchEventDetails functions. Moved connected user info to top-right corner. Hide title when logged in. Reduced vertical spacing for better layout. Made event filters more compact. All functionality restored and UI improved.
2025-08-19 16:09:57 +02:00
herel 73f72d1bbe feat: enhance login button styling and fix JavaScript syntax errors
Centered the login button on the page with improved styling including padding and shadow effects. Fixed JavaScript syntax errors in the event handling code. Removed unmatched closing div tags to ensure valid HTML structure.
2025-08-19 15:49:28 +02:00
herel a407a108ed fix: improve user authentication and account handling
Fixed issues with user display by fetching user info from userinfo endpoint

Improved error handling for JSON responses in schedule endpoint

Fixed account selection to use available accounts from config instead of default

Enhanced frontend to properly handle API responses and errors
2025-08-19 15:17:45 +02:00
herel d6277d7766 fix: improve authentication handling and error management 2025-08-19 15:04:53 +02:00
herel 5b1d741a16 feat: implement OpenID Connect authentication with Infomaniak 2025-08-19 11:16:07 +02:00
herel 5957868e0f chore: bump version to v0.5.2 2025-08-19 10:48:55 +02:00
herel 525d3bf326 fix: escape control characters in JSON output to make it jq compatible 2025-08-19 10:48:04 +02:00
herel 0e1eb0da3f chore: bump version to v0.5.1 2025-08-19 09:41:57 +02:00
herel 4b81cc7f9f fix: improve JSON response handling in API calls 2025-08-19 09:41:28 +02:00
herel c4d9236b16 feat: make config_section a global CLI option
Users can now specify --config-section once at the beginning of the command instead of repeating it for each subcommand. Also bumped version to v0.5.0 for this new feature.
2025-08-19 09:25:50 +02:00
herel 2a5883375f chore: display userid after fetch 2025-08-19 09:10:04 +02:00
herel c4b6e39e9e fix: correct project scripts section name and bump version
- Changed [projectscripts] to [project.scripts] in pyproject.toml to properly register the myice command line tool
- Bump version to v0.4.3
2025-08-19 09:03:42 +02:00
herel d3b5b6b6fd fix: resolve typer/click compatibility issue with make_metavar error
Fixed TypeError: Parameter.make_metavar() missing 1 required positional argument: 'ctx'
by correcting typer.Option(...) usage for optional parameters with default None values.

Bump version to v0.4.2
2025-08-19 08:57:37 +02:00
herel bcde9fccf5 chore: bump version to v0.4.1 and update dependencies 2025-08-19 08:52:39 +02:00
9 changed files with 2013 additions and 2696 deletions

No files matched your search

-1
View File
@@ -169,4 +169,3 @@ cython_debug/
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
strix_runs/*
+7 -11
View File
@@ -13,13 +13,9 @@ repos:
- id: check-yaml
- id: check-added-large-files
- id: detect-private-key
- repo: https://github.com/gitleaks/gitleaks
rev: v8.30.1
hooks:
- id: gitleaks
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.16.7
rev: v0.12.9
hooks:
# Run the linter.
- id: ruff
@@ -28,26 +24,26 @@ repos:
- id: ruff-format
args: [--diff, --target-version, py312]
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v2.3.1
rev: v1.17.1
hooks:
- id: mypy
exclude: ^(docs/|example-plugin/)
args: [--ignore-missing-imports]
additional_dependencies: [types-requests, pypdf]
- repo: https://github.com/adrienverge/yamllint.git
rev: v1.38.0
rev: v1.37.1
hooks:
- id: yamllint
args: [--strict]
- repo: https://github.com/markdownlint/markdownlint
rev: v0.18.1
rev: v0.13.0
hooks:
- id: markdownlint
exclude: "^.github|(^docs/_sidebar\\.md$)"
- repo: https://github.com/shellcheck-py/shellcheck-py
rev: v0.11.0.1-1
rev: v0.11.0.1
hooks:
- id: shellcheck
args: ["--severity=error"]
@@ -55,12 +51,12 @@ repos:
files: "\\.sh$"
- repo: https://github.com/golangci/misspell
rev: v0.8.0
rev: v0.7.0
hooks:
- id: misspell
args: ["-i", "charactor"]
- repo: https://github.com/python-poetry/poetry
rev: "2.4.1"
rev: "2.1.4"
hooks:
- id: poetry-check
- id: poetry-lock
+6 -17
View File
@@ -64,20 +64,17 @@ To fetch the `token` and `club_id` using mobile-login:
myice mobile-login
```
This will output the necessary information that you can add to your
`myice.ini` file.
This will output the necessary information that you can add to your `myice.ini` file.
## web interface
The tool includes a web interface that can be accessed by running the web
API server:
The tool includes a web interface that can be accessed by running the web API server:
```shell
uv run fastapi run myice/webapi.py
```
Then open your browser at `http://localhost:8000`. The web interface allows
you to:
Then open your browser at `http://localhost:8000`. The web interface allows you to:
- Select between different configured accounts
- View upcoming games and practices
@@ -91,24 +88,16 @@ The web interface supports two authentication methods:
Infomaniak" button to authenticate using Infomaniak's OIDC provider. Only
users in the allowed list will be granted access.
1. **Static API Key**: For development purposes, you can still use `abc` as
the token.
1. **Static API Key**: For development purposes, you can still use `abc` as the token.
### Environment Variables
To configure OIDC authentication, set the following environment variables:
- `CLIENT_ID`: Your OIDC client ID
(default: 8ea04fbb-4237-4b1d-a895-0b3575a3af3f)
- `CLIENT_ID`: Your OIDC client ID (default: 8ea04fbb-4237-4b1d-a895-0b3575a3af3f)
- `CLIENT_SECRET`: Your OIDC client secret
- `REDIRECT_URI`: The redirect URI (default: `http://localhost:8000/callback`)
- `ALLOWED_USERS`: Comma-separated list of allowed email addresses
(e.g. `"user1@example.com,user2@example.com"`)
- `ALLOWED_REDIRECT_URIS`: Comma-separated list of redirect targets external
apps may request via `/login?redirect_uri=...` (default: `myice://callback`
for the mobile app). Only allowlisted targets are honored; the callback
redirects back to the requesting app with the access token in the URL
fragment instead of the web frontend.
- `ALLOWED_USERS`: Comma-separated list of allowed email addresses (e.g., `"user1@example.com,user2@example.com"`)
The web API provides the following endpoints:
+47 -93
View File
@@ -13,7 +13,7 @@ import tempfile
from enum import Enum
from pathlib import Path
from typing import Annotated
from typing import List, Tuple
import pypdf
import requests
import typer
@@ -112,8 +112,8 @@ def sanitize_json_response(text):
app = typer.Typer(no_args_is_help=True)
session = requests.Session()
userid: int = 0
session: requests.Session
userid: int
global_config_section: str = "default"
@@ -245,6 +245,7 @@ def get_login(
def select_club(club_id: int = 172):
"""Select a club by ID after login."""
global session
r = session.get(
f"https://app.myice.hockey/?cl={club_id}", headers={"User-Agent": user_agent}
)
@@ -252,12 +253,14 @@ def select_club(club_id: int = 172):
def do_login(config_section: str | None = None):
global session
global userid
global global_config_section
# Use provided config_section, or fall back to global one
section = config_section if config_section is not None else global_config_section
username, password, userid_tmp, _token, club_id = get_login(config_section=section)
username, password, userid_tmp, token, club_id = get_login(config_section=section)
if userid_tmp is not None:
userid = userid_tmp
r = session.get("https://app.myice.hockey/", headers={"User-Agent": user_agent})
@@ -284,6 +287,7 @@ def do_login(config_section: str | None = None):
def get_userid():
global session
r = session.get(
"https://app.myice.hockey/players/clubschedule/",
headers={
@@ -302,7 +306,7 @@ def get_userid():
def wrapper_session(func):
def wrapper(*args, **kwargs):
global session, userid
global session, userid, global_config_section
# Use the global config_section
config_section = global_config_section
@@ -326,6 +330,8 @@ def wrapper_session(func):
@wrapper_session
def get_schedule(num_days: int) -> str:
global session
global userid
assert session and userid
now = datetime.datetime.now()
date_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
@@ -355,6 +361,7 @@ def get_schedule(num_days: int) -> str:
@wrapper_session
def game_pdf(gameid: int, outfile: Path):
global session, userid
assert session and userid
r = session.get(
f"https://app.myice.hockey/pdfexports/playerplatgamepdfgenerator.php?id={gameid}",
@@ -371,6 +378,7 @@ def game_pdf(gameid: int, outfile: Path):
@wrapper_session
def practice_pdf(gameid: int, outfile: Path):
global session, userid
assert session and userid
r = session.get(
f"https://app.myice.hockey/pdfexports/playerplatpracticepdf.php?practice={gameid}",
@@ -398,6 +406,7 @@ def schedule(
"""
Fetch schedule as json
"""
global global_config_section
schedule = get_schedule(num_days)
if outfile:
with outfile.open("w") as f:
@@ -418,7 +427,7 @@ def os_open(file: str) -> None:
subprocess.run(["open", file])
def extract_players(pdf_file: Path) -> list[str]:
def extract_players(pdf_file: Path) -> List[str]:
reader = pypdf.PdfReader(pdf_file)
page = reader.pages[0]
@@ -445,6 +454,7 @@ def get_game_pdf(
"""
Genate the pdf for the game invitation
"""
global global_config_section
if open_file:
output_filename = f"game_{game_id}.pdf"
else:
@@ -465,6 +475,7 @@ def get_practice_pdf(
"""
Genate the pdf for the practice invitation
"""
global global_config_section
output_filename = f"practice_{game_id}.pdf"
practice_pdf(game_id, Path(output_filename))
os_open(output_filename)
@@ -484,6 +495,7 @@ def parse_schedule(
"""
Parse schedule.json to look for specific games or practices
"""
global global_config_section
try:
with schedule_file.open("r") as f:
data = json.load(f)
@@ -533,6 +545,7 @@ def check_with_ai(
"""
Search through the schedule with natural language using Infomaniak LLM API
"""
global global_config_section
if not utils.init():
sys.exit(1)
with schedule_file.open("r") as f:
@@ -547,7 +560,7 @@ def check_with_ai(
system = "\n".join(
[
"Tu es une IA connaissant bien les données suivantes, qui décrivent les match et entraînements d'équipes de hockey sur glace.",
f"aujourd'hui, nous sommes le {when}",
f"aujourd'hui, nous sommes le {when}"
"attention: ce qu'il y a entre parenthèse après la catégorie est une catégorie à part entière, example, u13 a = U13 (A) et ça correspond aux agegroup",
"assure-toi de ne pas confondre les catégories d'age dans tes réponses. ne donne pas une réponse pour la mauvaise équipe",
"ne confond pas top, elite, a, prép",
@@ -556,7 +569,7 @@ def check_with_ai(
"```",
],
)
history: list[tuple[str, str]] = []
history: List[Tuple[str, str]] = []
while True:
try:
question = input("> ")
@@ -581,19 +594,20 @@ mobile_headers = {
}
# def curl_for_mobile_login(username: str, password: str) -> str:
# """Generate curl command to replicate mobile_login request."""
# import base64
def curl_for_mobile_login(username: str, password: str) -> str:
"""Generate curl command to replicate mobile_login request."""
import base64
# headers_str = " ".join([f"-H '{k}: {v}'" for k, v in mobile_headers.items()])
# encoded_username = base64.b64encode(username.encode()).decode()
# encoded_password = base64.b64encode(password.encode()).decode()
# return f"""curl -X POST "https://app.myice.hockey/api/mobilerest/login" \
# {headers_str} \
# -d 'login_email={encoded_username}&login_password={encoded_password}&language=FR&v=2'"""
headers_str = " ".join([f"-H '{k}: {v}'" for k, v in mobile_headers.items()])
encoded_username = base64.b64encode(username.encode()).decode()
encoded_password = base64.b64encode(password.encode()).decode()
return f"""curl -X POST "https://app.myice.hockey/api/mobilerest/login" \
{headers_str} \
-d 'login_email={encoded_username}&login_password={encoded_password}&language=FR&v=2'"""
def mobile_login(config_section: str | None = None):
global global_config_section
import base64
# Use provided config_section, or fall back to global one
@@ -605,7 +619,7 @@ def mobile_login(config_section: str | None = None):
print("Requesting token", file=sys.stderr)
# Print curl equivalent for debugging
# print(curl_for_mobile_login(username, password), file=sys.stderr)
print(curl_for_mobile_login(username, password), file=sys.stderr)
with requests.post(
"https://app.myice.hockey/api/mobilerest/login",
headers=mobile_headers,
@@ -629,26 +643,23 @@ userdata = {
}
# def curl_for_refresh_data(token: str, club_id: int) -> str:
# """Generate curl command to replicate refresh_data request."""
# headers_str = " ".join([f"-H '{k}: {v}'" for k, v in mobile_headers.items()])
# return f"""curl -X POST "https://app.myice.hockey/api/mobilerest/refreshdata" \
# {headers_str} \
# -d 'token={token}&id_club={club_id}&language=FR'"""
def curl_for_refresh_data(token: str, club_id: int) -> str:
"""Generate curl command to replicate refresh_data request."""
headers_str = " ".join([f"-H '{k}: {v}'" for k, v in mobile_headers.items()])
return f"""curl -X POST "https://app.myice.hockey/api/mobilerest/refreshdata" \
{headers_str} \
-d 'token={token}&id_club={club_id}&language=FR'"""
def refresh_data(user_data=None):
# Use provided user_data or fall back to global userdata for backward compatibility
if user_data is None:
user_data = userdata
def refresh_data():
# Print curl equivalent for debugging
# print(
# curl_for_refresh_data(user_data["token"], user_data["id_club"]), file=sys.stderr
# )
print(
curl_for_refresh_data(userdata["token"], userdata["id_club"]), file=sys.stderr
)
with requests.post(
"https://app.myice.hockey/api/mobilerest/refreshdata",
headers=mobile_headers,
data=f"token={user_data['token']}&id_club={user_data['id_club']}&language=FR",
data=f"token={userdata['token']}&id_club={userdata['id_club']}&language=FR",
# verify=False,
) as r:
r.raise_for_status()
@@ -672,14 +683,14 @@ def refresh_data(user_data=None):
@app.command("mobile-login")
def do_mobile_login():
global userdata
global userdata, global_config_section
userdata = mobile_login(config_section=global_config_section)
print(json.dumps(userdata, indent=2))
@app.command("mobile")
def mobile():
global userdata
global userdata, global_config_section
userdata = mobile_login(config_section=global_config_section)
games = [x for x in refresh_data().get("club_games")]
# Use built-in print to avoid rich formatting issues
@@ -688,22 +699,12 @@ def mobile():
builtins.print(json.dumps(games, indent=2, ensure_ascii=False))
@app.command("mobile-all")
def mobile_all():
global userdata
userdata = mobile_login(config_section=global_config_section)
# Use built-in print to avoid rich formatting issues
import builtins
builtins.print(json.dumps(refresh_data(), indent=2, ensure_ascii=False))
@app.command("mobile-game")
def mobile_game(
game_id: Annotated[int, typer.Argument(help="game id")],
raw: Annotated[bool, typer.Option(help="display raw output")] = False,
):
global userdata
global userdata, global_config_section
userdata = mobile_login(config_section=global_config_section)
with requests.post(
@@ -720,53 +721,6 @@ def mobile_game(
]
),
# verify=False,
) as r:
try:
data = json.loads(sanitize_json_response(r.text))["eventData"]
players = data["convocation"]["available"]
if raw:
print(data)
print(
f"Game {game_id}: {data['title']} ({data['type']}, {data['is_away']}) at {data['time_start']}"
)
print(f"{len(players)} players")
for player in sorted(
players,
key=lambda x: (
x["position"] if x["position"] else "none",
x["dob"],
),
):
print(
f"[{player['position']}] {player['fname']} {player['lname']} ({player['dob']})"
)
except KeyError:
print(sanitize_json_response(r.text))
@app.command("mobile-practice")
def mobile_practice(
game_id: Annotated[int, typer.Argument(help="game id")],
raw: Annotated[bool, typer.Option(help="display raw output")] = False,
):
global userdata
userdata = mobile_login(config_section=global_config_section)
with requests.post(
"https://app.myice.hockey/api/mobilerest/getevent",
headers=mobile_headers,
data="&".join(
[
f"token={userdata['token']}",
f"id_event={game_id}",
"type=practices",
f"id_player={userdata['id']}",
f"id_club={userdata['id_club']}",
"language=FR",
]
),
# verify=False,
) as r:
data = json.loads(sanitize_json_response(r.text))["eventData"]
@@ -774,7 +728,7 @@ def mobile_practice(
if raw:
print(data)
print(
f"Game {game_id}: {data['title']} ({data['type']}, {data.get('is_away')}) at {data['time_start']}"
f"Game {game_id}: {data['title']} ({data['type']}, {data['is_away']}) at {data['time_start']}"
)
print(f"{len(players)} players")
for player in sorted(
+41 -74
View File
@@ -2,14 +2,12 @@ import base64
import hashlib
import logging
import os
from typing import Annotated
import requests
from fastapi import FastAPI, Header, HTTPException, Request, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, RedirectResponse
from pydantic import BaseModel
from typing import Annotated
from . import myice
# OIDC Configuration
@@ -18,15 +16,6 @@ CLIENT_SECRET = os.environ.get("CLIENT_SECRET")
if not CLIENT_SECRET:
raise RuntimeError("CLIENT_SECRET environment variable must be set")
REDIRECT_URI = os.environ.get("REDIRECT_URI", "http://localhost:8000/callback")
FRONTEND_URL = os.environ.get("FRONTEND_URL", "/")
# Redirect targets an external app may request via /login?redirect_uri=...
# (comma-separated). Exact-match allowlist to prevent open redirects and
# access_token leakage to arbitrary hosts.
ALLOWED_REDIRECT_URIS = tuple(
uri.strip()
for uri in os.environ.get("ALLOWED_REDIRECT_URIS", "myice://callback").split(",")
if uri.strip()
)
SECRET_KEY = os.environ.get("SECRET_KEY", "change_me_in_production")
AUTHORIZATION_ENDPOINT = "https://login.infomaniak.com/authorize"
TOKEN_ENDPOINT = "https://login.infomaniak.com/token"
@@ -45,7 +34,6 @@ origins = (
if os.environ.get("ALLOWED_ORIGINS")
else []
)
logger = logging.getLogger(__name__)
app = FastAPI()
app.add_middleware(
@@ -61,9 +49,10 @@ block_endpoints = ["/health"]
class LogFilter(logging.Filter):
def filter(self, record):
return not (
record.args and len(record.args) >= 3 and record.args[2] in block_endpoints
)
if record.args and len(record.args) >= 3:
if record.args[2] in block_endpoints:
return False
return True
uvicorn_logger = logging.getLogger("uvicorn.access")
@@ -84,8 +73,11 @@ class AuthHeaders(BaseModel):
if not token:
return False
# No static key fallback; only valid OIDC tokens are accepted
return self.validate_oidc_token()
# First check if it's a valid OIDC token
if self.validate_oidc_token():
return True
# No static key fallback; reject non-OIDC tokens
return False
def validate_oidc_token(self) -> bool:
try:
@@ -108,9 +100,9 @@ class AuthHeaders(BaseModel):
issuer="https://login.infomaniak.com/",
)
user_email = payload_data.get("email")
return not (
ALLOWED_USERS and user_email and user_email not in ALLOWED_USERS
)
if ALLOWED_USERS and user_email and user_email not in ALLOWED_USERS:
return False
return True
# Fallback: validate opaque tokens by calling userinfo endpoint
userinfo_response = requests.get(
@@ -121,9 +113,9 @@ class AuthHeaders(BaseModel):
userinfo = userinfo_response.json()
user_email = userinfo.get("email")
return not (
ALLOWED_USERS and user_email and user_email not in ALLOWED_USERS
)
if ALLOWED_USERS and user_email and user_email not in ALLOWED_USERS:
return False
return True
except Exception:
return False
@@ -159,11 +151,12 @@ async def favico():
@app.get("/login")
async def login(request: Request, redirect_uri: str | None = None):
async def login(request: Request):
"""Redirect to Infomaniak OIDC login"""
import secrets
import urllib.parse
import secrets
state = secrets.token_urlsafe(32)
nonce = secrets.token_urlsafe(32)
@@ -190,55 +183,33 @@ async def login(request: Request, redirect_uri: str | None = None):
response = RedirectResponse(auth_url)
# Store state and nonce in secure http-only cookies for validation on callback
response.set_cookie(
"oidc_state", state, httponly=True, secure=True, samesite="lax", max_age=300
"oidc_state", state, httponly=True, secure=True, samesite="Lax", max_age=300
)
response.set_cookie(
"oidc_nonce", nonce, httponly=True, secure=True, samesite="lax", max_age=300
"oidc_nonce", nonce, httponly=True, secure=True, samesite="Lax", max_age=300
)
response.set_cookie(
"oidc_verifier",
code_verifier,
httponly=True,
secure=True,
samesite="lax",
samesite="Lax",
max_age=300,
)
# Preserve an app-specific redirect target (e.g. myice://callback for the
# mobile app) so /callback can send the user back to the requesting app.
if redirect_uri and redirect_uri in ALLOWED_REDIRECT_URIS:
response.set_cookie(
"oidc_target_redirect",
redirect_uri,
httponly=True,
secure=True,
samesite="lax",
max_age=300,
)
return response
@app.get("/callback")
async def callback(request: Request, code: str, state: str):
"""Handle OIDC callback and exchange code for token"""
import urllib.parse
expected_state = request.cookies.get("oidc_state")
code_verifier = request.cookies.get("oidc_verifier")
# Final redirect target requested by the calling app (e.g. myice://callback
# for the mobile app), falling back to the web frontend. The cookie value
# is re-validated against the allowlist to guard against cookie tossing.
target_redirect = request.cookies.get("oidc_target_redirect")
if target_redirect not in ALLOWED_REDIRECT_URIS:
target_redirect = FRONTEND_URL
# We should also delete state/nonce cookies once read
if not expected_state or state != expected_state:
response = RedirectResponse(
f"{target_redirect}?error={urllib.parse.quote('Invalid state parameter')}"
)
response = RedirectResponse("/?error=Invalid state parameter")
response.delete_cookie("oidc_state")
response.delete_cookie("oidc_nonce")
response.delete_cookie("oidc_verifier")
response.delete_cookie("oidc_target_redirect")
return response
# Exchange authorization code for access token
token_data = {
@@ -254,18 +225,16 @@ async def callback(request: Request, code: str, state: str):
if "access_token" not in token_response:
# Log the error for debugging
logger.error("Token exchange failed: %s", token_response)
logging.error("Token exchange failed: %s", token_response)
# Redirect back to frontend with error
frontend_url = os.environ.get("FRONTEND_URL", "/")
error_detail = token_response.get(
"error_description", "Failed to obtain access token"
)
error_response = RedirectResponse(
f"{target_redirect}?error={urllib.parse.quote(error_detail)}"
)
error_response = RedirectResponse(f"{frontend_url}?error={error_detail}")
error_response.delete_cookie("oidc_state")
error_response.delete_cookie("oidc_nonce")
error_response.delete_cookie("oidc_verifier")
error_response.delete_cookie("oidc_target_redirect")
return error_response
access_token = token_response["access_token"]
@@ -281,21 +250,19 @@ async def callback(request: Request, code: str, state: str):
user_email = userinfo.get("email")
if ALLOWED_USERS and user_email not in ALLOWED_USERS:
# Redirect back to frontend with error
response = RedirectResponse(
f"{target_redirect}?error={urllib.parse.quote('User not authorized')}"
)
frontend_url = os.environ.get("FRONTEND_URL", "/")
response = RedirectResponse(f"{frontend_url}?error=User not authorized")
response.delete_cookie("oidc_state")
response.delete_cookie("oidc_nonce")
response.delete_cookie("oidc_verifier")
response.delete_cookie("oidc_target_redirect")
return response
# Redirect back to frontend with access token and user info
response = RedirectResponse(f"{target_redirect}#access_token={access_token}")
frontend_url = os.environ.get("FRONTEND_URL", "/")
response = RedirectResponse(f"{frontend_url}#access_token={access_token}")
response.delete_cookie("oidc_state")
response.delete_cookie("oidc_nonce")
response.delete_cookie("oidc_verifier")
response.delete_cookie("oidc_target_redirect")
return response
@@ -374,26 +341,26 @@ async def schedule(
raise HTTPException(401, detail="get out")
try:
_username, _password, userid, existing_token, club_id = myice.get_login(
username, password, userid, existing_token, club_id = myice.get_login(
config_section=account
)
except Exception as e:
raise HTTPException(
400,
detail=f"Configuration error: {e!s}. Available accounts: isaac, leonard",
detail=f"Configuration error: {str(e)}. Available accounts: isaac, leonard",
)
try:
if existing_token:
user_data = {
myice.userdata = {
"id": userid,
"id_club": club_id or 186,
"token": existing_token,
}
else:
user_data = myice.mobile_login(config_section=account)
myice.userdata = myice.mobile_login(config_section=account)
data = myice.refresh_data(user_data=user_data)
data = myice.refresh_data()
if "club_games" in data:
return data["club_games"]
else:
@@ -462,17 +429,17 @@ async def game(
if not headers.authorized():
raise HTTPException(401, detail="get out")
_username, _password, userid, existing_token, club_id = myice.get_login(
username, password, userid, existing_token, club_id = myice.get_login(
config_section=account
)
if existing_token:
user_data = {
myice.userdata = {
"id": userid,
"id_club": club_id or 186,
"token": existing_token,
}
else:
user_data = myice.mobile_login(config_section=account)
myice.userdata = myice.mobile_login(config_section=account)
# data = refresh_data()
with requests.post(
@@ -480,11 +447,11 @@ async def game(
headers=myice.mobile_headers,
data="&".join(
[
f"token={user_data['token']}",
f"token={myice.userdata['token']}",
f"id_event={game_id}",
"type=games",
f"id_player={user_data['id']}",
f"id_club={user_data['id_club']}",
f"id_player={myice.userdata['id']}",
f"id_club={myice.userdata['id_club']}",
"language=FR",
]
),
Generated
+1586 -1630
View File
File diff suppressed because it is too large. Load diff
+9 -17
View File
@@ -10,20 +10,21 @@ readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"requests (>=2.34.2)",
"typer (>=0.27.2)",
"pypdf (>=6.19.0)",
"rl-ai-tools >=1.17.3",
"fastapi[standard] (>=0.141.1)",
"pyjwt[crypto] (>=2.14.0)",
"requests (>=2.32.3)",
"typer (>=0.15.1)",
"pypdf (>=6.0.0)",
"rl-ai-tools >=1.9.0",
"fastapi[standard] (>=0.115.11)",
"pyjwt[crypto] (>=2.10.1)",
]
[tool.poetry.dependencies]
rl-ai-tools = { source = "infomaniak" }
[tool.poetry.group.dev.dependencies]
types-requests = "^2.33.0.20260906"
mypy = "^2.3.1"
ipykernel = "^6.29.5"
types-requests = "^2.32.0.20241016"
mypy = "^1.15.0"
[[tool.poetry.source]]
@@ -31,15 +32,6 @@ name = "infomaniak"
url = "https://pypi.purple.infomaniak.ch"
priority = "supplemental"
[tool.ruff.lint]
ignore = [
"ASYNC210",
"BLE001",
"DTZ005",
"PLW1510",
"SIM115",
]
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
+35 -43
View File
@@ -1,55 +1,47 @@
--extra-index-url https://pypi.purple.infomaniak.ch
agent-detector==2.0.0 ; python_version >= "3.13"
annotated-doc==0.0.5 ; python_version >= "3.13"
annotated-types==0.8.0 ; python_version >= "3.13"
anyio==4.15.1 ; python_version >= "3.13"
certifi==2026.7.22 ; python_version >= "3.13"
cffi==2.1.1 ; python_version >= "3.13" and platform_python_implementation != "PyPy"
charset-normalizer==3.5.1 ; python_version >= "3.13"
click==8.5.0 ; python_version >= "3.13"
colorama==0.4.6 ; python_version >= "3.13" and platform_system == "Windows"
cryptography==50.0.1 ; python_version >= "3.13"
detect-installer==0.2.1 ; python_version >= "3.13"
annotated-doc==0.0.4 ; python_version >= "3.13"
annotated-types==0.7.0 ; python_version >= "3.13"
anyio==4.11.0 ; python_version >= "3.13"
certifi==2025.10.5 ; python_version >= "3.13"
charset-normalizer==3.4.4 ; python_version >= "3.13"
click==8.1.8 ; python_version >= "3.13"
colorama==0.4.6 ; (platform_system == "Windows" or sys_platform == "win32") and python_version >= "3.13"
dnspython==2.8.0 ; python_version >= "3.13"
email-validator==2.3.0 ; python_version >= "3.13"
fastapi-cli==0.0.32 ; python_version >= "3.13"
fastapi-cloud-cli==0.26.0 ; python_version >= "3.13"
fastapi==0.141.1 ; python_version >= "3.13"
fastar==0.12.0 ; python_version >= "3.13"
fastapi-cli==0.0.16 ; python_version >= "3.13"
fastapi-cloud-cli==0.3.1 ; python_version >= "3.13"
fastapi==0.121.1 ; python_version >= "3.13"
h11==0.16.0 ; python_version >= "3.13"
httpcore==1.0.9 ; python_version >= "3.13"
httptools==0.8.0 ; python_version >= "3.13"
httptools==0.7.1 ; python_version >= "3.13"
httpx==0.28.1 ; python_version >= "3.13"
idna==3.19 ; python_version >= "3.13"
idna==3.11 ; python_version >= "3.13"
jinja2==3.1.6 ; python_version >= "3.13"
markdown-it-py==4.2.0 ; python_version >= "3.13"
markdown-it-py==4.0.0 ; python_version >= "3.13"
markupsafe==3.0.3 ; python_version >= "3.13"
mdurl==0.1.2 ; python_version >= "3.13"
pycparser==3.0 ; implementation_name != "PyPy" and platform_python_implementation != "PyPy" and python_version >= "3.13"
pydantic-core==2.46.5 ; python_version >= "3.13"
pydantic-extra-types==2.11.1 ; python_version >= "3.13"
pydantic-settings==2.15.0 ; python_version >= "3.13"
pydantic==2.13.5 ; python_version >= "3.13"
pygments==2.21.0 ; python_version >= "3.13"
pyjwt==2.14.0 ; python_version >= "3.13"
pypdf==6.19.0 ; python_version >= "3.13"
python-dotenv==1.2.3 ; python_version >= "3.13"
python-multipart==0.0.32 ; python_version >= "3.13"
pydantic-core==2.41.5 ; python_version >= "3.13"
pydantic==2.12.4 ; python_version >= "3.13"
pygments==2.19.2 ; python_version >= "3.13"
pypdf==6.2.0 ; python_version >= "3.13"
python-dotenv==1.2.1 ; python_version >= "3.13"
python-multipart==0.0.20 ; python_version >= "3.13"
pyyaml==6.0.3 ; python_version >= "3.13"
requests==2.34.2 ; python_version >= "3.13"
rich-toolkit==0.20.5 ; python_version >= "3.13"
rich==15.0.0 ; python_version >= "3.13"
rignore==0.8.1 ; python_version >= "3.13"
rl-ai-tools==1.17.3 ; python_version >= "3.13"
sentry-sdk==2.69.2 ; python_version >= "3.13"
requests==2.32.5 ; python_version >= "3.13"
rich-toolkit==0.15.1 ; python_version >= "3.13"
rich==14.2.0 ; python_version >= "3.13"
rignore==0.7.6 ; python_version >= "3.13"
rl-ai-tools==1.15.0 ; python_version >= "3.13"
sentry-sdk==2.43.0 ; python_version >= "3.13"
shellingham==1.5.4 ; python_version >= "3.13"
starlette==1.6.0 ; python_version >= "3.13"
typer==0.27.2 ; python_version >= "3.13"
typing-extensions==4.16.0 ; python_version >= "3.13"
typing-inspection==0.4.4 ; python_version >= "3.13"
urllib3==2.8.0 ; python_version >= "3.13"
uvicorn==0.53.0 ; python_version >= "3.13"
uvloop==0.22.1 ; platform_python_implementation != "PyPy" and sys_platform != "win32" and sys_platform != "cygwin" and python_version >= "3.13"
watchfiles==1.2.0 ; python_version >= "3.13"
websockets==17.1 ; python_version >= "3.13"
sniffio==1.3.1 ; python_version >= "3.13"
starlette==0.49.3 ; python_version >= "3.13"
typer==0.15.4 ; python_version >= "3.13"
typing-extensions==4.15.0 ; python_version >= "3.13"
typing-inspection==0.4.2 ; python_version >= "3.13"
urllib3==2.5.0 ; python_version >= "3.13"
uvicorn==0.38.0 ; python_version >= "3.13"
uvloop==0.22.1 ; sys_platform != "win32" and sys_platform != "cygwin" and platform_python_implementation != "PyPy" and python_version >= "3.13"
watchfiles==1.1.1 ; python_version >= "3.13"
websockets==15.0.1 ; python_version >= "3.13"
Generated
+282 -810
View File
File diff suppressed because it is too large. Load diff