diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..8e689c5 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(fastapi --help:*)", + "Bash(uv run fastapi run --help:*)" + ] + } +} diff --git a/Dockerfile b/Dockerfile index ed32852..5825ea0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -50,4 +50,4 @@ ENV ABR_APP__PORT=8000 ARG VERSION ENV ABR_APP__VERSION=$VERSION -CMD /app/.venv/bin/alembic upgrade heads && /app/.venv/bin/fastapi run --port $ABR_APP__PORT +CMD ["/bin/sh", "-c", "/app/.venv/bin/alembic upgrade heads && /app/.venv/bin/fastapi run --port $ABR_APP__PORT --proxy-headers --forwarded-allow-ips=\"${FORWARDED_ALLOW_IPS:-0.0.0.0/0}\""] diff --git a/alembic/versions/a1b2c3d4e5f6_remove_oidc_redirect_https.py b/alembic/versions/a1b2c3d4e5f6_remove_oidc_redirect_https.py new file mode 100644 index 0000000..f78495b --- /dev/null +++ b/alembic/versions/a1b2c3d4e5f6_remove_oidc_redirect_https.py @@ -0,0 +1,56 @@ +"""remove oidc_redirect_https setting + +Revision ID: a1b2c3d4e5f6 +Revises: d0fac85afd0f +Create Date: 2026-01-22 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "a1b2c3d4e5f6" +down_revision: Union[str, None] = "d0fac85afd0f" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Remove the deprecated oidc_redirect_https config key from the database + op.execute("DELETE FROM config WHERE key = 'oidc_redirect_https'") + + print(""" + ╔════════════════════════════════════════════════════════════════════╗ + ║ OIDC Protocol Detection - Automatic! ║ + ╚════════════════════════════════════════════════════════════════════╝ + + The manual oidc_redirect_https setting has been removed. + + Protocol (http/https) is now automatically detected from requests! + + Default Behavior: + - All proxy headers are trusted (0.0.0.0/0) + - Works out-of-the-box for all setups + - Perfect for home labs and self-hosted instances + + For Production Security (Optional): + Configure FORWARDED_ALLOW_IPS to only trust your reverse proxy IP. + You'll see a warning in logs if you should do this. + + Example docker-compose.yml: + environment: + - FORWARDED_ALLOW_IPS=172.17.0.1 + + For multiple proxies: FORWARDED_ALLOW_IPS=172.17.0.1,10.0.0.1 + For IP ranges: FORWARDED_ALLOW_IPS=172.17.0.0/16 + + Learn more: https://github.com/markbeep/AudioBookRequest/docs/oidc + """) + + +def downgrade() -> None: + # Cannot restore - users would need to reconfigure manually + pass diff --git a/app/internal/auth/oidc_config.py b/app/internal/auth/oidc_config.py index 8cae52f..9c7526a 100644 --- a/app/internal/auth/oidc_config.py +++ b/app/internal/auth/oidc_config.py @@ -18,7 +18,6 @@ "oidc_token_endpoint", "oidc_userinfo_endpoint", "oidc_authorize_endpoint", - "oidc_redirect_https", "oidc_logout_url", ] @@ -70,11 +69,6 @@ async def set_endpoint( f"Failed to set OIDC endpoint: {endpoint}. Error: {str(e)}" ) from None - def get_redirect_https(self, session: Session) -> bool: - if self.get(session, "oidc_redirect_https"): - return True - return False - async def validate( self, session: Session, client_session: ClientSession ) -> str | None: diff --git a/app/routers/api/settings/security.py b/app/routers/api/settings/security.py index 6502a3c..0b2b59b 100644 --- a/app/routers/api/settings/security.py +++ b/app/routers/api/settings/security.py @@ -29,7 +29,6 @@ class SecuritySettings(BaseModel): oidc_scope: str oidc_username_claim: str oidc_group_claim: str - oidc_redirect_https: bool oidc_logout_url: str force_login_type: LoginTypeEnum | None @@ -55,7 +54,6 @@ def get_security_settings( oidc_scope=oidc_config.get(session, "oidc_scope", ""), oidc_username_claim=oidc_config.get(session, "oidc_username_claim", ""), oidc_group_claim=oidc_config.get(session, "oidc_group_claim", ""), - oidc_redirect_https=oidc_config.get_redirect_https(session), oidc_logout_url=oidc_config.get(session, "oidc_logout_url", ""), force_login_type=force_login_type, ) @@ -80,7 +78,6 @@ class UpdateSecuritySettings(BaseModel): oidc_scope: Optional[str] = None oidc_username_claim: Optional[str] = None oidc_group_claim: Optional[str] = None - oidc_redirect_https: Optional[bool] = None oidc_logout_url: Optional[str] = None @@ -130,12 +127,6 @@ async def update_security_settings( oidc_config.set(session, "oidc_scope", body.oidc_scope) if body.oidc_username_claim: oidc_config.set(session, "oidc_username_claim", body.oidc_username_claim) - if body.oidc_redirect_https: - oidc_config.set( - session, - "oidc_redirect_https", - "true" if body.oidc_redirect_https else "", - ) if body.oidc_logout_url: oidc_config.set(session, "oidc_logout_url", body.oidc_logout_url) if body.oidc_group_claim is not None: diff --git a/app/routers/auth.py b/app/routers/auth.py index db69512..4d23ec5 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -1,6 +1,8 @@ import base64 +import os import secrets import time +import uuid from typing import Annotated, cast from urllib.parse import urlencode, urljoin @@ -41,6 +43,59 @@ router = APIRouter(prefix="/auth") +def _validate_proxy_headers(request: Request) -> None: + """ + Validate that proxy headers come from trusted IPs. + + Raises HTTPException(403) if proxy headers are present but were not trusted + by Uvicorn (indicating a misconfigured FORWARDED_ALLOW_IPS). + + This prevents silent security degradation while keeping server config private. + Basic rejection is logged at INFO level; detailed debugging info at DEBUG level. + """ + x_forwarded_proto = request.headers.get("X-Forwarded-Proto") + + # No proxy headers present - direct access is fine + if not x_forwarded_proto: + return + + # Proxy headers present - check if Uvicorn trusted them by verifying + # that request.url.scheme was updated to match X-Forwarded-Proto. + # If Uvicorn didn't trust the source IP, it would ignore the header + # and request.url.scheme would still be "http" (the actual protocol). + if request.url.scheme != x_forwarded_proto.lower(): + # Headers were sent but not trusted - fail closed + client_ip = request.client.host if request.client else "unknown" + forwarded_allow_ips = os.getenv("FORWARDED_ALLOW_IPS", "0.0.0.0/0") + error_id = str(uuid.uuid4()) + + # Log basic rejection at INFO level (always visible, helps spot misconfiguration) + logger.info( + "Request rejected: untrusted proxy headers", + error_id=error_id, + client_ip=client_ip, + x_forwarded_proto=x_forwarded_proto, + ) + + # Log detailed debugging info at DEBUG level (for deeper troubleshooting) + logger.debug( + "Proxy header validation failed - debug details", + error_id=error_id, + client_ip=client_ip, + x_forwarded_proto=x_forwarded_proto, + request_scheme=request.url.scheme, + forwarded_allow_ips=forwarded_allow_ips, + fix_option_1=f"Set FORWARDED_ALLOW_IPS={client_ip}", + fix_option_2="Set FORWARDED_ALLOW_IPS=0.0.0.0/0 to trust all", + ) + + # Return generic error with error ID so user can share with admin + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Access denied. Please contact your administrator. (Error ID: {error_id})", + ) + + @router.get("/login") async def login( request: Request, @@ -48,6 +103,54 @@ async def login( redirect_uri: str = "/", backup: bool = False, ): + # ===== SECURITY: Validate Proxy Headers ===== + _validate_proxy_headers(request) + # ===== END SECURITY VALIDATION ===== + + # ===== DEBUG: Request Connection & Headers ===== + logger.debug( + "LOGIN ENDPOINT DEBUG", + # Connection info + client_ip=request.client.host if request.client else "unknown", + client_port=request.client.port if request.client else "unknown", + # Request URL components + request_url_full=str(request.url), + request_scheme=request.url.scheme, + request_host=request.url.netloc, + request_path=request.url.path, + request_query=request.url.query, + # Query parameters passed in + redirect_uri_param=redirect_uri, + backup_param=backup, + # All proxy headers + x_forwarded_proto=request.headers.get("X-Forwarded-Proto"), + x_forwarded_host=request.headers.get("X-Forwarded-Host"), + x_forwarded_for=request.headers.get("X-Forwarded-For"), + x_forwarded_port=request.headers.get("X-Forwarded-Port"), + # Request headers + host_header=request.headers.get("Host"), + user_agent=request.headers.get("User-Agent"), + # Scope details (raw ASGI scope) + asgi_scheme=request.scope.get("scheme"), + asgi_server_host=request.scope.get("server", ["?", "?"])[0], + asgi_server_port=request.scope.get("server", ["?", "?"])[1], + ) + # ===== END DEBUG ===== + + # ===== SECURITY WARNING: Proxy Headers with Permissive Config ===== + # Warn if proxy headers are detected but FORWARDED_ALLOW_IPS allows all IPs + if request.headers.get("X-Forwarded-Proto"): + forwarded_allow_ips = os.getenv("FORWARDED_ALLOW_IPS", "0.0.0.0/0") + if forwarded_allow_ips in ("0.0.0.0/0", "*"): + logger.warning( + "SECURITY WARNING: Proxy headers detected (X-Forwarded-Proto) but " + "FORWARDED_ALLOW_IPS is set to allow all IPs (0.0.0.0/0). " + "For production use, set FORWARDED_ALLOW_IPS to your reverse proxy IP(s) only. " + "This prevents header spoofing attacks. " + "See documentation: https://github.com/markbeep/AudioBookRequest/docs/oidc" + ) + # ===== END SECURITY WARNING ===== + login_type = auth_config.get(session, "login_type") if login_type in [LoginTypeEnum.basic, LoginTypeEnum.none]: return BaseUrlRedirectResponse(redirect_uri) @@ -80,9 +183,24 @@ async def login( if not client_id: raise InvalidOIDCConfiguration("Missing OIDC client ID") + # DEBUG: Show what we're building the redirect URI from + logger.debug( + "OIDC_CONFIG_DEBUG", + oidc_authorize_endpoint=authorize_endpoint, + oidc_client_id=client_id, + oidc_scope=scope, + ) + auth_redirect_uri = urljoin(str(request.url), "/auth/oidc") - if oidc_config.get_redirect_https(session): - auth_redirect_uri = auth_redirect_uri.replace("http:", "https:") + + # DEBUG: Show the built redirect URI + logger.debug( + "REDIRECT_URI_BUILD_DEBUG", + request_url_before_join=str(request.url), + path_being_joined="/auth/oidc", + final_auth_redirect_uri=auth_redirect_uri, + scheme_of_redirect_uri=auth_redirect_uri.split("://")[0] if "://" in auth_redirect_uri else "UNKNOWN", + ) logger.info( "Redirecting to OIDC login", @@ -162,6 +280,31 @@ async def login_oidc( code: str, state: str | None = None, ): + # ===== SECURITY: Validate Proxy Headers ===== + _validate_proxy_headers(request) + # ===== END SECURITY VALIDATION ===== + + # ===== DEBUG: OIDC Callback Request ===== + logger.debug( + "OIDC_CALLBACK_DEBUG", + # Connection info + client_ip=request.client.host if request.client else "unknown", + # Request URL components + request_url_full=str(request.url), + request_scheme=request.url.scheme, + request_host=request.url.netloc, + request_path=request.url.path, + # Query parameters + code_param=code[:20] + "..." if len(code) > 20 else code, + state_param=state[:20] + "..." if state and len(state) > 20 else state, + # Proxy headers + x_forwarded_proto=request.headers.get("X-Forwarded-Proto"), + x_forwarded_host=request.headers.get("X-Forwarded-Host"), + x_forwarded_for=request.headers.get("X-Forwarded-For"), + asgi_scheme=request.scope.get("scheme"), + ) + # ===== END DEBUG ===== + token_endpoint = oidc_config.get(session, "oidc_token_endpoint") userinfo_endpoint = oidc_config.get(session, "oidc_userinfo_endpoint") client_id = oidc_config.get(session, "oidc_client_id") @@ -181,8 +324,14 @@ async def login_oidc( raise InvalidOIDCConfiguration("Missing OIDC username claim") auth_redirect_uri = urljoin(str(request.url), "/auth/oidc") - if oidc_config.get_redirect_https(session): - auth_redirect_uri = auth_redirect_uri.replace("http:", "https:") + + # DEBUG: Show the redirect URI being sent to token endpoint + logger.debug( + "OIDC_CALLBACK_REDIRECT_URI_DEBUG", + request_url_for_join=str(request.url), + final_auth_redirect_uri=auth_redirect_uri, + scheme=auth_redirect_uri.split("://")[0] if "://" in auth_redirect_uri else "UNKNOWN", + ) data = { "grant_type": "authorization_code", diff --git a/app/routers/settings/security.py b/app/routers/settings/security.py index c6a211f..f2d896d 100644 --- a/app/routers/settings/security.py +++ b/app/routers/settings/security.py @@ -51,7 +51,6 @@ def read_security( "oidc_scope": oidc_config.get(session, "oidc_scope", ""), "oidc_username_claim": oidc_config.get(session, "oidc_username_claim", ""), "oidc_group_claim": oidc_config.get(session, "oidc_group_claim", ""), - "oidc_redirect_https": oidc_config.get_redirect_https(session), "oidc_logout_url": oidc_config.get(session, "oidc_logout_url", ""), "force_login_type": force_login_type, }, @@ -82,7 +81,6 @@ async def update_security( oidc_scope: Annotated[str | None, Form()] = None, oidc_username_claim: Annotated[str | None, Form()] = None, oidc_group_claim: Annotated[str | None, Form()] = None, - oidc_redirect_https: Annotated[bool | None, Form()] = None, oidc_logout_url: Annotated[str | None, Form()] = None, ): try: @@ -97,7 +95,6 @@ async def update_security( oidc_scope=oidc_scope, oidc_username_claim=oidc_username_claim, oidc_group_claim=oidc_group_claim, - oidc_redirect_https=oidc_redirect_https, oidc_logout_url=oidc_logout_url, ), session, @@ -129,7 +126,6 @@ async def update_security( "oidc_group_claim": oidc_config.get(session, "oidc_group_claim", ""), "oidc_client_secret": oidc_config.get(session, "oidc_client_secret", ""), "oidc_endpoint": oidc_config.get(session, "oidc_endpoint", ""), - "oidc_redirect_https": oidc_config.get_redirect_https(session), "oidc_logout_url": oidc_config.get(session, "oidc_logout_url", ""), "force_login_type": force_login_type, "success": "Settings updated", diff --git a/docs/content/docs/tutorials/oidc/_index.md b/docs/content/docs/tutorials/oidc/_index.md index 1bd88ce..94c841b 100644 --- a/docs/content/docs/tutorials/oidc/_index.md +++ b/docs/content/docs/tutorials/oidc/_index.md @@ -20,14 +20,12 @@ for other providers. `Client ID` and `Client Secret`. Take note of those. You should also set the redirect URL that the OIDC provider will redirect you to after a succesful login. This has to be the domain of your ABR instance with `/auth/oidc` - appended. + appended. If you access ABR through multiple URLs (e.g., local VPN and + external domain), add **all** of them as separate redirect URIs in your + provider. ![Authentik Provider](authentik-provider.png) - {{< alert color="warning" title="Warning" >}} Make sure you correctly set - `http` or `https` in the redirect URL. This depends on how you access - AudioBookRequest. {{< /alert >}} - 4. Set the scopes that ABR can get access to. You should always allow for the `openid` scope. Any other scopes are optional. You'll have to check with your OIDC provider to see what what scopes are required to get a @@ -38,6 +36,83 @@ for other providers. 5. Assign your newly created provider to the ABR application. +## Reverse Proxy Configuration + +If you access AudioBookRequest through a reverse proxy (Nginx, Traefik, Caddy, etc.), it automatically works! The protocol (http/https) is detected from the proxy headers. + +### How It Works + +When behind a reverse proxy with SSL termination: +1. External users access: `https://domain.com` +2. Reverse proxy forwards to ABR: `http://backend:8000` (terminates SSL) +3. Proxy sends `X-Forwarded-Proto: https` header +4. ABR automatically reads this and generates correct OIDC redirect URIs + +### Default Behavior (No Configuration Needed) + +By default, AudioBookRequest **allows all proxy IPs** (`0.0.0.0/0`) for compatibility: +- ✅ Works out-of-the-box for all reverse proxies +- ✅ Great for home labs and self-hosted setups +- ⚠️ Less secure on shared infrastructure (susceptible to header spoofing) + +If you see this warning in your logs: +``` +🔐 SECURITY WARNING: Proxy headers detected (X-Forwarded-Proto) but +FORWARDED_ALLOW_IPS is set to allow all IPs (0.0.0.0/0). +``` + +It means you should configure specific proxy IPs for security. + +### Secure Setup (Recommended for Production) + +To only trust your reverse proxy, configure `FORWARDED_ALLOW_IPS`: + +Find your proxy's IP address: +```bash +docker exec audiobookrequest ip route | grep default +# Output: default via 172.17.0.1 dev eth0 +``` + +Set it in docker-compose: +```yaml +# docker-compose.yml +services: + abr: + image: ghcr.io/markbeep/audiobookrequest:latest + environment: + - FORWARDED_ALLOW_IPS=172.17.0.1 +``` + +**Multiple proxies:** `FORWARDED_ALLOW_IPS=172.17.0.1,10.0.0.1` + +**IP ranges:** `FORWARDED_ALLOW_IPS=172.17.0.0/16` + +**Kubernetes:** +```yaml +env: + - name: FORWARDED_ALLOW_IPS + value: "10.96.0.0/16" # Service CIDR +``` + +### Ensure Proxy Sends Headers + +### Configure OIDC Provider with Multiple Redirect URIs + +In Authentik (or your OIDC provider), add **ALL** redirect URIs you'll use: +- `http://192.168.1.100:8000/auth/oidc` (local VPN access) +- `https://external.domain.com/auth/oidc` (external access) +- `http://localhost:8000/auth/oidc` (development) + +ABR automatically uses the correct one based on how you accessed the login page! + +{{< alert color="success" title="Automatic Detection" >}} +Protocol (http/https) is now detected automatically - no manual configuration in ABR settings! +{{< /alert >}} + +{{< alert color="warning" title="Security Note" >}} +For production, use `FORWARDED_ALLOW_IPS` to only trust specific proxy IPs. This prevents header spoofing attacks. +{{< /alert >}} + ## Setup settings in ABR 1. On AudioBookRequest, head to `Settings>Security` and set the "Login Type" to @@ -55,15 +130,15 @@ for other providers. 5. "OIDC Username Claim" **has to be a unique identifier** which is used as the username for the user. `sub` is always available, but you might prefere to use `email` or `username` (with the correctly added scope). -6. Depending on what you used above for the redirect URL, set `http` or `https`. - {{< alert color="warning" title="Warning" >}} `http/s` has to match-up with - what protocol your redirect-url uses. Providers _will_ reject logins if this - does not match up. {{< /alert >}} -7. _Optional_: The "OIDC Logout URL" is where you're redirected if you select to +6. _Optional_: The "OIDC Logout URL" is where you're redirected if you select to log out in ABR. OIDC Providers allow you to invalidate the session on this URL. While this value is optional, not adding it might break logging out slightly because the session can't properly be invalidated. + {{< alert color="info" title="Protocol Detection" >}} + The protocol (http/https) is now detected automatically from incoming requests! + No manual configuration needed. {{< /alert >}} + ## Groups "OIDC Group Claim" is optional, but allows you to handle the role distribution diff --git a/templates/settings_page/security.html b/templates/settings_page/security.html index 25c576d..5f4d281 100644 --- a/templates/settings_page/security.html +++ b/templates/settings_page/security.html @@ -143,19 +143,6 @@

Login/Security

name="oidc_group_claim" class="input w-full" value="{{ oidc_group_claim }}" /> -
- -

- After you login on your authentication server, you will be - redirected to /auth/oidc. Determine - if you should be redirected to http or https. This should match up - with what you configured as the redirect URL in your OIDC provider. -

-
-