From 5a4408d577aed2339e77e323dba26154bc2d6df4 Mon Sep 17 00:00:00 2001 From: Rene Luria Date: Tue, 22 Sep 2026 17:09:37 +0200 Subject: [PATCH] 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. --- README.md | 7 ++++++- myice/webapi.py | 51 +++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 49 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 86ce8fa..89a1bb8 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,12 @@ To configure OIDC authentication, set the following environment variables: - `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"`) + (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: diff --git a/myice/webapi.py b/myice/webapi.py index 7f61120..bedaddd 100644 --- a/myice/webapi.py +++ b/myice/webapi.py @@ -18,6 +18,15 @@ 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" @@ -150,7 +159,7 @@ async def favico(): @app.get("/login") -async def login(request: Request): +async def login(request: Request, redirect_uri: str | None = None): """Redirect to Infomaniak OIDC login""" import secrets import urllib.parse @@ -194,20 +203,42 @@ async def login(request: Request): 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("/?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_nonce") response.delete_cookie("oidc_verifier") + response.delete_cookie("oidc_target_redirect") return response # Exchange authorization code for access token token_data = { @@ -225,14 +256,16 @@ async def callback(request: Request, code: str, state: str): # Log the error for debugging logger.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"{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_nonce") error_response.delete_cookie("oidc_verifier") + error_response.delete_cookie("oidc_target_redirect") return error_response access_token = token_response["access_token"] @@ -248,19 +281,21 @@ 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 - frontend_url = os.environ.get("FRONTEND_URL", "/") - response = RedirectResponse(f"{frontend_url}?error=User not authorized") + response = RedirectResponse( + f"{target_redirect}?error={urllib.parse.quote('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 - frontend_url = os.environ.get("FRONTEND_URL", "/") - response = RedirectResponse(f"{frontend_url}#access_token={access_token}") + response = RedirectResponse(f"{target_redirect}#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