Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(fastapi --help:*)",
"Bash(uv run fastapi run --help:*)"
]
}
}
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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}\""]
56 changes: 56 additions & 0 deletions alembic/versions/a1b2c3d4e5f6_remove_oidc_redirect_https.py
Original file line number Diff line number Diff line change
@@ -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
6 changes: 0 additions & 6 deletions app/internal/auth/oidc_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
"oidc_token_endpoint",
"oidc_userinfo_endpoint",
"oidc_authorize_endpoint",
"oidc_redirect_https",
"oidc_logout_url",
]

Expand Down Expand Up @@ -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:
Expand Down
9 changes: 0 additions & 9 deletions app/routers/api/settings/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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,
)
Expand All @@ -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


Expand Down Expand Up @@ -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:
Expand Down
157 changes: 153 additions & 4 deletions app/routers/auth.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -41,13 +43,114 @@
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,
session: Annotated[Session, Depends(get_session)],
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)
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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")
Expand All @@ -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",
Expand Down
4 changes: 0 additions & 4 deletions app/routers/settings/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down Expand Up @@ -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:
Expand All @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
Loading