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
3 changed files with 53 additions and 9 deletions

No files matched your search

+4
View File
@@ -13,6 +13,10 @@ 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.16.7 rev: v0.16.7
+6 -1
View File
@@ -103,7 +103,12 @@ To configure OIDC authentication, set the following environment variables:
- `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 - `ALLOWED_USERS`: Comma-separated list of allowed email addresses
(e.g., `"user1@example.com,user2@example.com"`) (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:
+43 -8
View File
@@ -18,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"
@@ -150,7 +159,7 @@ 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 secrets import secrets
import urllib.parse import urllib.parse
@@ -194,20 +203,42 @@ async def login(request: Request):
samesite="lax", samesite="lax",
max_age=300, 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 return response
@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,14 +256,16 @@ async def callback(request: Request, code: str, state: str):
# Log the error for debugging # Log the error for debugging
logger.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"]
@@ -248,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