Auth Gateway
+{{ title }}
+{{ message }}
+diff --git a/containers/auth-gateway/Dockerfile-auth-gateway b/containers/auth-gateway/Dockerfile-auth-gateway new file mode 100644 index 0000000..a9dc09f --- /dev/null +++ b/containers/auth-gateway/Dockerfile-auth-gateway @@ -0,0 +1,18 @@ +FROM python:3.12-alpine + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +RUN apk add --no-cache bash + +COPY requirements.txt /app/requirements.txt +RUN pip install --no-cache-dir -r /app/requirements.txt + +COPY auth_gateway /app/auth_gateway +COPY entrypoint.sh /app/entrypoint.sh + +RUN chmod +x /app/entrypoint.sh + +ENTRYPOINT ["/app/entrypoint.sh"] diff --git a/containers/auth-gateway/auth_gateway/__init__.py b/containers/auth-gateway/auth_gateway/__init__.py new file mode 100644 index 0000000..c670073 --- /dev/null +++ b/containers/auth-gateway/auth_gateway/__init__.py @@ -0,0 +1 @@ +"""Auth gateway package.""" diff --git a/containers/auth-gateway/auth_gateway/config_loader.py b/containers/auth-gateway/auth_gateway/config_loader.py new file mode 100644 index 0000000..8a1a556 --- /dev/null +++ b/containers/auth-gateway/auth_gateway/config_loader.py @@ -0,0 +1,121 @@ +import json +import logging +from pathlib import Path +from urllib.parse import urlparse + +from auth_gateway.models.applications import ApplicationsFileModel +from auth_gateway.models.auth import AuthPoliciesFileModel, OIDCMethodModel, PolicyModel +from auth_gateway.models.routes import RoutesFileModel +from auth_gateway.models.runtime import RuntimeConfig + +logger = logging.getLogger(__name__) + + +def _load_json(path: Path) -> dict | None: + if not path.exists(): + return None + with path.open("r", encoding="utf-8") as handle: + return json.load(handle) + + +def _is_valid_provider_url(method_name: str, method: OIDCMethodModel) -> bool: + if not method.provider: + logger.warning("OIDC method '%s' has no provider URL configured — it will be unavailable.", method_name) + return False + parsed = urlparse(method.provider) + hostname = parsed.hostname or "" + if parsed.scheme not in {"http", "https"} or not hostname or ".." in hostname: + logger.warning("OIDC method '%s' has an invalid provider URL ('%s') — it will be unavailable.", method_name, method.provider) + return False + return True + + +def _load_applications(config_dir: Path) -> dict: + combined = {} + for filename in ("wireguard_webadmin.json", "applications.json"): + payload = _load_json(config_dir / filename) + if not payload: + continue + parsed = ApplicationsFileModel.model_validate(payload) + for entry in parsed.entries: + if entry.id in combined: + raise ValueError(f"Duplicate application id detected: '{entry.id}'.") + combined[entry.id] = entry + return combined + + +def load_runtime_config(config_dir: Path) -> RuntimeConfig: + applications = _load_applications(config_dir) + + routes_payload = _load_json(config_dir / "routes.json") or {} + auth_payload = _load_json(config_dir / "auth_policies.json") or {} + + routes = RoutesFileModel.model_validate(routes_payload) + auth = AuthPoliciesFileModel.model_validate(auth_payload) + + valid_auth_methods = {} + for method_name, method in auth.auth_methods.items(): + if isinstance(method, OIDCMethodModel) and not _is_valid_provider_url(method_name, method): + continue + valid_auth_methods[method_name] = method + auth.auth_methods = valid_auth_methods + + for app_id in routes.entries: + if app_id not in applications: + raise ValueError(f"Routes reference unknown application '{app_id}'.") + + for app_id, route_config in routes.entries.items(): + if route_config.default_policy and route_config.default_policy not in auth.policies: + raise ValueError(f"Application '{app_id}' references unknown default policy '{route_config.default_policy}'.") + for route in route_config.routes: + if route.policy not in auth.policies: + raise ValueError(f"Application '{app_id}' route '{route.path_prefix}' references unknown policy '{route.policy}'.") + + for policy_name, policy in auth.policies.items(): + for group_name in policy.groups: + if group_name not in auth.groups: + raise ValueError(f"Policy '{policy_name}' references unknown group '{group_name}'.") + for method_name in policy.methods: + if method_name not in auth.auth_methods: + logger.warning( + "Policy '%s' references unavailable method '%s' — policy forced to deny.", + policy_name, method_name, + ) + auth.policies[policy_name] = PolicyModel(policy_type="deny") + break + + return RuntimeConfig( + applications=applications, + routes_by_app=routes.entries, + auth_methods=auth.auth_methods, + users=auth.users, + groups=auth.groups, + policies=auth.policies, + ) + + +class RuntimeConfigStore: + def __init__(self, config_dir: Path): + self.config_dir = config_dir + self._runtime_config: RuntimeConfig | None = None + self._mtimes: dict[str, int] = {} + + def _current_mtimes(self) -> dict[str, int]: + mtimes = {} + for filename in ( + "wireguard_webadmin.json", + "applications.json", + "routes.json", + "auth_policies.json", + ): + path = self.config_dir / filename + if path.exists(): + mtimes[filename] = path.stat().st_mtime_ns + return mtimes + + def get(self) -> RuntimeConfig: + current_mtimes = self._current_mtimes() + if self._runtime_config is None or current_mtimes != self._mtimes: + self._runtime_config = load_runtime_config(self.config_dir) + self._mtimes = current_mtimes + return self._runtime_config diff --git a/containers/auth-gateway/auth_gateway/main.py b/containers/auth-gateway/auth_gateway/main.py new file mode 100644 index 0000000..f911891 --- /dev/null +++ b/containers/auth-gateway/auth_gateway/main.py @@ -0,0 +1,37 @@ +from contextlib import asynccontextmanager +from pathlib import Path + +from auth_gateway.config_loader import RuntimeConfigStore +from auth_gateway.services.oidc_service import OIDCService +from auth_gateway.services.session_service import SessionService +from auth_gateway.settings import settings +from auth_gateway.storage.sqlite import SQLiteStorage +from auth_gateway.web.auth_routes import router as auth_router +from auth_gateway.web.login_routes import router as login_router +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates + +BASE_DIR = Path(__file__).resolve().parent + + +@asynccontextmanager +async def lifespan(app: FastAPI): + storage = SQLiteStorage(settings.database_path) + app.state.settings = settings + app.state.config_store = RuntimeConfigStore(settings.config_dir) + app.state.session_service = SessionService(storage, settings.session_default_minutes, settings.oidc_state_ttl_minutes) + app.state.oidc_service = OIDCService() + app.state.templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) + yield + + +app = FastAPI(title="Auth Gateway", lifespan=lifespan) +app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static") +app.include_router(auth_router) +app.include_router(login_router) + + +@app.get("/health") +async def healthcheck(): + return {"status": "ok"} diff --git a/containers/auth-gateway/auth_gateway/models/__init__.py b/containers/auth-gateway/auth_gateway/models/__init__.py new file mode 100644 index 0000000..33e2869 --- /dev/null +++ b/containers/auth-gateway/auth_gateway/models/__init__.py @@ -0,0 +1 @@ +"""Auth gateway data models.""" diff --git a/containers/auth-gateway/auth_gateway/models/applications.py b/containers/auth-gateway/auth_gateway/models/applications.py new file mode 100644 index 0000000..f0b63d1 --- /dev/null +++ b/containers/auth-gateway/auth_gateway/models/applications.py @@ -0,0 +1,20 @@ +from pydantic import BaseModel, Field + + +class StaticRouteModel(BaseModel): + path_prefix: str + root: str + strip_prefix: str | None = None + cache_control: str | None = None + + +class ApplicationModel(BaseModel): + id: str + name: str + hosts: list[str] = Field(default_factory=list) + upstream: str + static_routes: list[StaticRouteModel] = Field(default_factory=list) + + +class ApplicationsFileModel(BaseModel): + entries: list[ApplicationModel] = Field(default_factory=list) diff --git a/containers/auth-gateway/auth_gateway/models/auth.py b/containers/auth-gateway/auth_gateway/models/auth.py new file mode 100644 index 0000000..fdc73b3 --- /dev/null +++ b/containers/auth-gateway/auth_gateway/models/auth.py @@ -0,0 +1,65 @@ +from typing import Annotated, Literal + +from pydantic import BaseModel, Field + + +class IPRuleModel(BaseModel): + address: str + prefix_length: int | None = None + action: Literal["allow", "deny"] + description: str | None = "" + + +class TotpMethodModel(BaseModel): + type: Literal["totp"] + totp_secret: str | None = None + session_expiration_minutes: int | None = None + + +class LocalPasswordMethodModel(BaseModel): + type: Literal["local_password"] + session_expiration_minutes: int = 720 + + +class OIDCMethodModel(BaseModel): + type: Literal["oidc"] + provider: str + client_id: str + client_secret: str + allowed_domains: list[str] = Field(default_factory=list) + allowed_emails: list[str] = Field(default_factory=list) + session_expiration_minutes: int = 720 + + +class IPAddressMethodModel(BaseModel): + type: Literal["ip_address"] + rules: list[IPRuleModel] = Field(default_factory=list) + + +AuthMethodModel = Annotated[ + TotpMethodModel | LocalPasswordMethodModel | OIDCMethodModel | IPAddressMethodModel, + Field(discriminator="type"), +] + + +class UserModel(BaseModel): + email: str | None = "" + password_hash: str | None = None + totp_secret: str | None = "" + + +class GroupModel(BaseModel): + users: list[str] = Field(default_factory=list) + + +class PolicyModel(BaseModel): + policy_type: Literal["bypass", "deny", "protected"] + groups: list[str] = Field(default_factory=list) + methods: list[str] = Field(default_factory=list) + + +class AuthPoliciesFileModel(BaseModel): + auth_methods: dict[str, AuthMethodModel] = Field(default_factory=dict) + groups: dict[str, GroupModel] = Field(default_factory=dict) + users: dict[str, UserModel] = Field(default_factory=dict) + policies: dict[str, PolicyModel] = Field(default_factory=dict) diff --git a/containers/auth-gateway/auth_gateway/models/routes.py b/containers/auth-gateway/auth_gateway/models/routes.py new file mode 100644 index 0000000..37527ca --- /dev/null +++ b/containers/auth-gateway/auth_gateway/models/routes.py @@ -0,0 +1,16 @@ +from pydantic import BaseModel, Field + + +class RoutePolicyBindingModel(BaseModel): + id: str | None = None + path_prefix: str + policy: str + + +class AppRoutesModel(BaseModel): + routes: list[RoutePolicyBindingModel] = Field(default_factory=list) + default_policy: str | None = None + + +class RoutesFileModel(BaseModel): + entries: dict[str, AppRoutesModel] = Field(default_factory=dict) diff --git a/containers/auth-gateway/auth_gateway/models/runtime.py b/containers/auth-gateway/auth_gateway/models/runtime.py new file mode 100644 index 0000000..169074a --- /dev/null +++ b/containers/auth-gateway/auth_gateway/models/runtime.py @@ -0,0 +1,13 @@ +from auth_gateway.models.applications import ApplicationModel +from auth_gateway.models.auth import AuthMethodModel, GroupModel, PolicyModel, UserModel +from auth_gateway.models.routes import AppRoutesModel +from pydantic import BaseModel, Field + + +class RuntimeConfig(BaseModel): + applications: dict[str, ApplicationModel] = Field(default_factory=dict) + routes_by_app: dict[str, AppRoutesModel] = Field(default_factory=dict) + auth_methods: dict[str, AuthMethodModel] = Field(default_factory=dict) + users: dict[str, UserModel] = Field(default_factory=dict) + groups: dict[str, GroupModel] = Field(default_factory=dict) + policies: dict[str, PolicyModel] = Field(default_factory=dict) diff --git a/containers/auth-gateway/auth_gateway/models/session.py b/containers/auth-gateway/auth_gateway/models/session.py new file mode 100644 index 0000000..66a7342 --- /dev/null +++ b/containers/auth-gateway/auth_gateway/models/session.py @@ -0,0 +1,26 @@ +from datetime import datetime + +from pydantic import BaseModel, Field + + +class SessionRecord(BaseModel): + session_id: str + created_at: datetime + updated_at: datetime + expires_at: datetime + username: str | None = None + email: str | None = None + subject: str | None = None + groups: list[str] = Field(default_factory=list) + auth_factors: list[str] = Field(default_factory=list) + metadata: dict = Field(default_factory=dict) + + +class OIDCStateRecord(BaseModel): + state: str + nonce: str + method_name: str + host: str + next_url: str + created_at: datetime + expires_at: datetime diff --git a/containers/auth-gateway/auth_gateway/services/__init__.py b/containers/auth-gateway/auth_gateway/services/__init__.py new file mode 100644 index 0000000..e02cd2d --- /dev/null +++ b/containers/auth-gateway/auth_gateway/services/__init__.py @@ -0,0 +1 @@ +"""Auth gateway service layer.""" diff --git a/containers/auth-gateway/auth_gateway/services/oidc_service.py b/containers/auth-gateway/auth_gateway/services/oidc_service.py new file mode 100644 index 0000000..1a3d294 --- /dev/null +++ b/containers/auth-gateway/auth_gateway/services/oidc_service.py @@ -0,0 +1,59 @@ +from dataclasses import dataclass + +from auth_gateway.models.auth import OIDCMethodModel +from authlib.integrations.starlette_client import OAuth + + +@dataclass +class OIDCIdentity: + subject: str | None + email: str | None + claims: dict + + +class OIDCService: + def __init__(self): + self.oauth = OAuth() + self._clients = {} + + def _client(self, method_name: str, method: OIDCMethodModel): + if method_name in self._clients: + return self._clients[method_name] + metadata_url = f"{method.provider.rstrip('/')}/.well-known/openid-configuration" + client = self.oauth.register( + name=f"oidc_{method_name}", + client_id=method.client_id, + client_secret=method.client_secret, + server_metadata_url=metadata_url, + client_kwargs={"scope": "openid email profile"}, + ) + self._clients[method_name] = client + return client + + async def build_authorization_redirect(self, request, method_name: str, method: OIDCMethodModel, redirect_uri: str, state: str, nonce: str): + client = self._client(method_name, method) + return await client.authorize_redirect(request, redirect_uri, state=state, nonce=nonce) + + async def finish_callback(self, request, method_name: str, method: OIDCMethodModel, nonce: str) -> OIDCIdentity: + client = self._client(method_name, method) + token = await client.authorize_access_token(request) + claims = {} + if "userinfo" in token and isinstance(token["userinfo"], dict): + claims = token["userinfo"] + elif "id_token" in token: + claims = await client.parse_id_token(request, token, nonce=nonce) + email = claims.get("email") + subject = claims.get("sub") + return OIDCIdentity(subject=subject, email=email, claims=dict(claims)) + + +def is_oidc_identity_allowed(method: OIDCMethodModel, email: str | None) -> bool: + if not email: + return not method.allowed_domains and not method.allowed_emails + normalized_email = email.lower() + normalized_domain = normalized_email.split("@", 1)[1] if "@" in normalized_email else "" + allowed_emails = {item.lower() for item in method.allowed_emails} + allowed_domains = {item.lower() for item in method.allowed_domains} + if not allowed_emails and not allowed_domains: + return True + return normalized_email in allowed_emails or normalized_domain in allowed_domains diff --git a/containers/auth-gateway/auth_gateway/services/password_service.py b/containers/auth-gateway/auth_gateway/services/password_service.py new file mode 100644 index 0000000..da824ca --- /dev/null +++ b/containers/auth-gateway/auth_gateway/services/password_service.py @@ -0,0 +1,18 @@ +from argon2 import PasswordHasher +from argon2.exceptions import VerifyMismatchError + +from auth_gateway.models.auth import UserModel + + +password_hasher = PasswordHasher() + + +def verify_user_password(username: str, password: str, users: dict[str, UserModel]) -> UserModel | None: + user = users.get(username) + if not user or not user.password_hash: + return None + try: + password_hasher.verify(user.password_hash, password) + except VerifyMismatchError: + return None + return user diff --git a/containers/auth-gateway/auth_gateway/services/policy_engine.py b/containers/auth-gateway/auth_gateway/services/policy_engine.py new file mode 100644 index 0000000..0d85a55 --- /dev/null +++ b/containers/auth-gateway/auth_gateway/services/policy_engine.py @@ -0,0 +1,106 @@ +import ipaddress +from dataclasses import dataclass, field + +from auth_gateway.models.auth import ( + IPAddressMethodModel, + IPRuleModel, + LocalPasswordMethodModel, + OIDCMethodModel, + PolicyModel, + TotpMethodModel, +) +from auth_gateway.models.runtime import RuntimeConfig + + +@dataclass +class EffectivePolicy: + name: str + mode: str + required_factors: list[str] = field(default_factory=list) + allowed_users: set[str] = field(default_factory=set) + allowed_groups: set[str] = field(default_factory=set) + ip_method_names: list[str] = field(default_factory=list) + totp_method_names: list[str] = field(default_factory=list) + password_method_names: list[str] = field(default_factory=list) + oidc_method_names: list[str] = field(default_factory=list) + factor_expirations: dict[str, int] = field(default_factory=dict) + + +def expand_policy_users(runtime_config: RuntimeConfig, policy: PolicyModel) -> set[str]: + usernames: set[str] = set() + for group_name in policy.groups: + group = runtime_config.groups.get(group_name) + if group: + usernames.update(group.users) + return usernames + + +def build_effective_policy(runtime_config: RuntimeConfig, policy_name: str) -> EffectivePolicy | None: + policy = runtime_config.policies.get(policy_name) + if not policy: + return None + + effective = EffectivePolicy( + name=policy_name, + mode=policy.policy_type, + allowed_users=expand_policy_users(runtime_config, policy), + allowed_groups=set(policy.groups), + ) + + if policy.policy_type != "protected": + return effective + + for method_name in policy.methods: + method = runtime_config.auth_methods[method_name] + if isinstance(method, IPAddressMethodModel): + effective.ip_method_names.append(method_name) + if "ip" not in effective.required_factors: + effective.required_factors.append("ip") + elif isinstance(method, LocalPasswordMethodModel): + effective.password_method_names.append(method_name) + effective.required_factors.append("password") + effective.factor_expirations["password"] = method.session_expiration_minutes + elif isinstance(method, TotpMethodModel): + effective.totp_method_names.append(method_name) + effective.required_factors.append("totp") + effective.factor_expirations["totp"] = method.session_expiration_minutes or 720 + elif isinstance(method, OIDCMethodModel): + effective.oidc_method_names.append(method_name) + effective.required_factors.append("oidc") + effective.factor_expirations["oidc"] = method.session_expiration_minutes + + return effective + + +def extract_client_ip(forwarded_for: str) -> str | None: + if not forwarded_for: + return None + candidate = forwarded_for.split(",")[0].strip() + try: + return str(ipaddress.ip_address(candidate)) + except ValueError: + return None + + +def evaluate_ip_rules(client_ip: str | None, rules: list[IPRuleModel]) -> bool: + if not client_ip: + return False + ip_value = ipaddress.ip_address(client_ip) + for rule in rules: + if rule.prefix_length is None: + network = ipaddress.ip_network(f"{rule.address}/{'32' if ip_value.version == 4 else '128'}", strict=False) + else: + network = ipaddress.ip_network(f"{rule.address}/{rule.prefix_length}", strict=False) + if ip_value in network: + return rule.action == "allow" + return False + + +def evaluate_ip_access(runtime_config: RuntimeConfig, effective_policy: EffectivePolicy, client_ip: str | None) -> bool: + if not effective_policy.ip_method_names: + return True + for method_name in effective_policy.ip_method_names: + method = runtime_config.auth_methods[method_name] + if isinstance(method, IPAddressMethodModel) and evaluate_ip_rules(client_ip, method.rules): + return True + return False diff --git a/containers/auth-gateway/auth_gateway/services/resolver.py b/containers/auth-gateway/auth_gateway/services/resolver.py new file mode 100644 index 0000000..e410c17 --- /dev/null +++ b/containers/auth-gateway/auth_gateway/services/resolver.py @@ -0,0 +1,63 @@ +from dataclasses import dataclass +from urllib.parse import urlsplit + +from auth_gateway.models.applications import ApplicationModel +from auth_gateway.models.routes import AppRoutesModel, RoutePolicyBindingModel +from auth_gateway.models.runtime import RuntimeConfig + + +@dataclass +class RequestContext: + host: str + path: str + application: ApplicationModel + route: RoutePolicyBindingModel | None + policy_name: str + + +def normalize_host(raw_host: str) -> str: + return raw_host.split(":", 1)[0].strip().lower() + + +def normalize_path(raw_uri: str) -> str: + parsed = urlsplit(raw_uri or "/") + path = parsed.path or "/" + return path if path.startswith("/") else f"/{path}" + + +def resolve_application(runtime_config: RuntimeConfig, host: str) -> ApplicationModel | None: + normalized_host = normalize_host(host) + for application in runtime_config.applications.values(): + if normalized_host in {candidate.lower() for candidate in application.hosts}: + return application + return None + + +def resolve_route(runtime_config: RuntimeConfig, application_id: str, path: str) -> tuple[RoutePolicyBindingModel | None, str | None]: + app_routes: AppRoutesModel | None = runtime_config.routes_by_app.get(application_id) + if not app_routes: + return None, None + + normalized_path = normalize_path(path) + sorted_routes = sorted(app_routes.routes, key=lambda route: len(route.path_prefix), reverse=True) + for route in sorted_routes: + route_prefix = normalize_path(route.path_prefix) + if normalized_path.startswith(route_prefix): + return route, route.policy + return None, app_routes.default_policy + + +def resolve_request_context(runtime_config: RuntimeConfig, host: str, path: str) -> RequestContext | None: + application = resolve_application(runtime_config, host) + if not application: + return None + route, policy_name = resolve_route(runtime_config, application.id, path) + if not policy_name: + return None + return RequestContext( + host=normalize_host(host), + path=normalize_path(path), + application=application, + route=route, + policy_name=policy_name, + ) diff --git a/containers/auth-gateway/auth_gateway/services/session_service.py b/containers/auth-gateway/auth_gateway/services/session_service.py new file mode 100644 index 0000000..9457194 --- /dev/null +++ b/containers/auth-gateway/auth_gateway/services/session_service.py @@ -0,0 +1,89 @@ +from datetime import UTC, datetime, timedelta +from secrets import token_urlsafe + +from auth_gateway.models.session import OIDCStateRecord, SessionRecord +from auth_gateway.storage.sqlite import SQLiteStorage + + +class SessionService: + def __init__(self, storage: SQLiteStorage, default_session_minutes: int, oidc_state_ttl_minutes: int): + self.storage = storage + self.default_session_minutes = default_session_minutes + self.oidc_state_ttl_minutes = oidc_state_ttl_minutes + + def get_session(self, session_id: str | None) -> SessionRecord | None: + if not session_id: + return None + session = self.storage.get_session(session_id) + if not session: + return None + if session.expires_at <= datetime.now(UTC): + self.storage.delete_session(session_id) + return None + return session + + def issue_session( + self, + existing_session: SessionRecord | None = None, + *, + username: str | None = None, + email: str | None = None, + subject: str | None = None, + groups: list[str] | None = None, + add_factors: list[str] | None = None, + metadata: dict | None = None, + expires_in_minutes: int | None = None, + ) -> SessionRecord: + now = datetime.now(UTC) + session = existing_session or SessionRecord( + session_id=token_urlsafe(32), + created_at=now, + updated_at=now, + expires_at=now + timedelta(minutes=expires_in_minutes or self.default_session_minutes), + ) + if username is not None: + session.username = username + if email is not None: + session.email = email + if subject is not None: + session.subject = subject + if groups is not None: + session.groups = groups + if metadata: + session.metadata.update(metadata) + if add_factors: + merged_factors = set(session.auth_factors) + merged_factors.update(add_factors) + session.auth_factors = sorted(merged_factors) + requested_expiry = now + timedelta(minutes=expires_in_minutes or self.default_session_minutes) + session.expires_at = min(session.expires_at, requested_expiry) if existing_session else requested_expiry + session.updated_at = now + self.storage.save_session(session) + return session + + def delete_session(self, session_id: str | None) -> None: + if session_id: + self.storage.delete_session(session_id) + + def create_oidc_state(self, method_name: str, host: str, next_url: str) -> OIDCStateRecord: + now = datetime.now(UTC) + state = OIDCStateRecord( + state=token_urlsafe(24), + nonce=token_urlsafe(24), + method_name=method_name, + host=host, + next_url=next_url, + created_at=now, + expires_at=now + timedelta(minutes=self.oidc_state_ttl_minutes), + ) + self.storage.save_oidc_state(state) + return state + + def consume_oidc_state(self, state_value: str) -> OIDCStateRecord | None: + oidc_state = self.storage.get_oidc_state(state_value) + if not oidc_state: + return None + self.storage.delete_oidc_state(state_value) + if oidc_state.expires_at <= datetime.now(UTC): + return None + return oidc_state diff --git a/containers/auth-gateway/auth_gateway/services/totp_service.py b/containers/auth-gateway/auth_gateway/services/totp_service.py new file mode 100644 index 0000000..4e5701a --- /dev/null +++ b/containers/auth-gateway/auth_gateway/services/totp_service.py @@ -0,0 +1,9 @@ +import pyotp + + +def verify_totp(secret: str, token: str) -> bool: + normalized_secret = secret.strip() + normalized_token = token.strip().replace(" ", "") + if not normalized_secret or not normalized_token: + return False + return pyotp.TOTP(normalized_secret).verify(normalized_token, valid_window=1) diff --git a/containers/auth-gateway/auth_gateway/settings.py b/containers/auth-gateway/auth_gateway/settings.py new file mode 100644 index 0000000..9d0717d --- /dev/null +++ b/containers/auth-gateway/auth_gateway/settings.py @@ -0,0 +1,19 @@ +from pathlib import Path + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_prefix="AUTH_GATEWAY_", extra="ignore") + + config_dir: Path = Field(default=Path("/caddy_json_export")) + database_path: Path = Field(default=Path("/data/auth-gateway.sqlite3")) + cookie_name: str = Field(default="auth_gateway_session") + external_path: str = Field(default="/auth-gateway") + secure_cookies: bool = Field(default=True) + session_default_minutes: int = Field(default=720) + oidc_state_ttl_minutes: int = Field(default=10) + + +settings = Settings() diff --git a/containers/auth-gateway/auth_gateway/static/style.css b/containers/auth-gateway/auth_gateway/static/style.css new file mode 100644 index 0000000..e9394e8 --- /dev/null +++ b/containers/auth-gateway/auth_gateway/static/style.css @@ -0,0 +1,117 @@ +:root { + color-scheme: light; + --bg: #f2efe7; + --bg-accent: #ddd4c4; + --card: rgba(255, 252, 246, 0.92); + --ink: #1e1b18; + --muted: #6a6258; + --line: rgba(30, 27, 24, 0.12); + --accent: #6b3f24; + --accent-strong: #4c2714; + --danger: #a0251d; + --shadow: 0 30px 80px rgba(62, 40, 25, 0.16); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + font-family: "IBM Plex Sans", "Segoe UI", sans-serif; + color: var(--ink); + background: + radial-gradient(circle at top left, rgba(107, 63, 36, 0.14), transparent 32%), + radial-gradient(circle at bottom right, rgba(93, 124, 96, 0.14), transparent 28%), + linear-gradient(180deg, var(--bg) 0%, var(--bg-accent) 100%); +} + +.shell { + min-height: 100vh; + display: grid; + place-items: center; + padding: 24px; +} + +.card { + width: min(100%, 440px); + padding: 32px; + border-radius: 24px; + background: var(--card); + border: 1px solid var(--line); + box-shadow: var(--shadow); +} + +.eyebrow { + margin: 0 0 12px; + text-transform: uppercase; + letter-spacing: 0.14em; + font-size: 0.72rem; + color: var(--muted); +} + +h1 { + margin: 0 0 12px; + font-family: "IBM Plex Serif", Georgia, serif; + font-size: clamp(1.9rem, 4vw, 2.3rem); + line-height: 1.05; +} + +.muted { + margin: 0 0 20px; + color: var(--muted); +} + +.stack { + display: grid; + gap: 14px; +} + +.field { + display: grid; + gap: 8px; + font-size: 0.95rem; +} + +input { + width: 100%; + padding: 13px 14px; + border-radius: 14px; + border: 1px solid var(--line); + background: rgba(255, 255, 255, 0.9); + color: var(--ink); + font: inherit; +} + +.button { + display: inline-flex; + justify-content: center; + align-items: center; + min-height: 48px; + padding: 12px 16px; + border: 0; + border-radius: 14px; + text-decoration: none; + background: var(--accent); + color: #fff; + font-weight: 600; + cursor: pointer; + transition: transform 180ms ease, background 180ms ease; +} + +.button:hover { + transform: translateY(-1px); + background: var(--accent-strong); +} + +.button.secondary { + background: rgba(30, 27, 24, 0.08); + color: var(--ink); +} + +.error { + margin: 0 0 16px; + color: var(--danger); + font-weight: 600; +} diff --git a/containers/auth-gateway/auth_gateway/storage/__init__.py b/containers/auth-gateway/auth_gateway/storage/__init__.py new file mode 100644 index 0000000..039d2e9 --- /dev/null +++ b/containers/auth-gateway/auth_gateway/storage/__init__.py @@ -0,0 +1 @@ +"""Storage backends for auth gateway.""" diff --git a/containers/auth-gateway/auth_gateway/storage/sqlite.py b/containers/auth-gateway/auth_gateway/storage/sqlite.py new file mode 100644 index 0000000..1df4b45 --- /dev/null +++ b/containers/auth-gateway/auth_gateway/storage/sqlite.py @@ -0,0 +1,154 @@ +import json +import sqlite3 +import threading +from datetime import UTC, datetime +from pathlib import Path + +from auth_gateway.models.session import OIDCStateRecord, SessionRecord + + +class SQLiteStorage: + def __init__(self, database_path: Path): + self.database_path = database_path + self._lock = threading.Lock() + self.database_path.parent.mkdir(parents=True, exist_ok=True) + self._initialize() + + def _connect(self) -> sqlite3.Connection: + connection = sqlite3.connect(self.database_path, check_same_thread=False) + connection.row_factory = sqlite3.Row + return connection + + def _initialize(self) -> None: + with self._lock, self._connect() as connection: + connection.execute( + """ + CREATE TABLE IF NOT EXISTS sessions ( + session_id TEXT PRIMARY KEY, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + username TEXT, + email TEXT, + subject TEXT, + groups_json TEXT NOT NULL, + auth_factors_json TEXT NOT NULL, + metadata_json TEXT NOT NULL + ) + """ + ) + connection.execute( + """ + CREATE TABLE IF NOT EXISTS oidc_states ( + state TEXT PRIMARY KEY, + nonce TEXT NOT NULL, + method_name TEXT NOT NULL, + host TEXT NOT NULL, + next_url TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL + ) + """ + ) + connection.commit() + + @staticmethod + def _to_iso(value: datetime) -> str: + return value.astimezone(UTC).isoformat() + + @staticmethod + def _from_iso(value: str) -> datetime: + return datetime.fromisoformat(value) + + def save_session(self, session: SessionRecord) -> None: + with self._lock, self._connect() as connection: + connection.execute( + """ + INSERT OR REPLACE INTO sessions ( + session_id, created_at, updated_at, expires_at, username, email, subject, + groups_json, auth_factors_json, metadata_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + session.session_id, + self._to_iso(session.created_at), + self._to_iso(session.updated_at), + self._to_iso(session.expires_at), + session.username, + session.email, + session.subject, + json.dumps(session.groups), + json.dumps(session.auth_factors), + json.dumps(session.metadata), + ), + ) + connection.commit() + + def get_session(self, session_id: str) -> SessionRecord | None: + with self._lock, self._connect() as connection: + row = connection.execute( + "SELECT * FROM sessions WHERE session_id = ?", + (session_id,), + ).fetchone() + if not row: + return None + return SessionRecord( + session_id=row["session_id"], + created_at=self._from_iso(row["created_at"]), + updated_at=self._from_iso(row["updated_at"]), + expires_at=self._from_iso(row["expires_at"]), + username=row["username"], + email=row["email"], + subject=row["subject"], + groups=json.loads(row["groups_json"]), + auth_factors=json.loads(row["auth_factors_json"]), + metadata=json.loads(row["metadata_json"]), + ) + + def delete_session(self, session_id: str) -> None: + with self._lock, self._connect() as connection: + connection.execute("DELETE FROM sessions WHERE session_id = ?", (session_id,)) + connection.commit() + + def save_oidc_state(self, oidc_state: OIDCStateRecord) -> None: + with self._lock, self._connect() as connection: + connection.execute( + """ + INSERT OR REPLACE INTO oidc_states ( + state, nonce, method_name, host, next_url, created_at, expires_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + oidc_state.state, + oidc_state.nonce, + oidc_state.method_name, + oidc_state.host, + oidc_state.next_url, + self._to_iso(oidc_state.created_at), + self._to_iso(oidc_state.expires_at), + ), + ) + connection.commit() + + def get_oidc_state(self, state: str) -> OIDCStateRecord | None: + with self._lock, self._connect() as connection: + row = connection.execute( + "SELECT * FROM oidc_states WHERE state = ?", + (state,), + ).fetchone() + if not row: + return None + return OIDCStateRecord( + state=row["state"], + nonce=row["nonce"], + method_name=row["method_name"], + host=row["host"], + next_url=row["next_url"], + created_at=self._from_iso(row["created_at"]), + expires_at=self._from_iso(row["expires_at"]), + ) + + def delete_oidc_state(self, state: str) -> None: + with self._lock, self._connect() as connection: + connection.execute("DELETE FROM oidc_states WHERE state = ?", (state,)) + connection.commit() diff --git a/containers/auth-gateway/auth_gateway/templates/error.html b/containers/auth-gateway/auth_gateway/templates/error.html new file mode 100644 index 0000000..c8c538a --- /dev/null +++ b/containers/auth-gateway/auth_gateway/templates/error.html @@ -0,0 +1,18 @@ + + +
+ + +Auth Gateway
+{{ message }}
+Auth Gateway
+Active policy: {{ policy_name }}
+Auth Gateway
+Enter your local username and password.
+ {% if error %} +{{ error }}
+ {% endif %} + +Auth Gateway
+Enter the current code from your authenticator app.
+ {% if error %} +{{ error }}
+ {% endif %} + +