7 Commits
Author SHA1 Message Date
herel 5a4408d577 fix(api): Preserve app redirect_uri through OIDC flow
Apps initiating login with their own redirect target (e.g.
myice://callback for the mobile app) always ended up on the web
frontend because /callback ignored the requested target and
redirected to FRONTEND_URL.

Store the requested target in an httpOnly cookie at /login, validated
against an ALLOWED_REDIRECT_URIS allowlist, and redirect back to it
at /callback on all paths. Re-validate the cookie value when read to
guard against cookie tossing, drop the '/' prefix that broke deep
links on the error path, and URL-encode error details in redirects.
2026-09-22 17:09:37 +02:00
herel 6c76b8ac78 chore: add gitleaks 2026-09-16 15:59:08 +02:00
herel ca5f7d7c61 build: Update dependencies and pre-commit hooks
Bump mypy to 2.3.1 and refresh all pre-commit hooks (ruff 0.16,
markdownlint 0.18). Fix issues surfaced by the new tooling:

- Use lowercase samesite cookie values (mypy 2.x)
- Remove no-op global declarations and address new ruff findings;
  ignore rules that would require behavior changes
- Add missing comma in AI system prompt (strings were concatenated)
- Wrap long README lines for MD013
2026-09-16 15:23:57 +02:00
herel 0269a65adc build: Upgrade dependencies to latest versions
Bump minimum versions for requests, typer, pypdf, rl-ai-tools,
fastapi and pyjwt, and update dev dependencies (mypy,
types-requests). Remove unused ipykernel from dev group.

Regenerate poetry.lock, re-export requirements.txt and sync
uv.lock accordingly. requirements.txt now also includes pyjwt
and its crypto dependencies which were missing from the
previous export.
2026-09-16 13:10:24 +02:00
herel dbbe2171c2 chore: update .gitignore 2026-06-29 23:11:51 +02:00
herel 09952e4dc1 fix: remove Cross-Account Data Leakage 2026-06-29 23:04:48 +02:00
herel c7990b0971 chore: remove debugging prints 2026-06-29 23:03:48 +02:00
9 changed files with 2703 additions and 2020 deletions

No files matched your search

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