{{ t("auth.login.register") }}
-
-
-
+
diff --git a/client/src/locales/de.json b/client/src/locales/de.json
index ae4d1f433..438eaaeb4 100644
--- a/client/src/locales/de.json
+++ b/client/src/locales/de.json
@@ -77,7 +77,8 @@
"auth": {
"login": {
"register": "Registrieren",
- "login": "Anmelden"
+ "login": "Anmelden",
+ "loginWith": "Anmeldung über OIDC"
}
},
"core": {
diff --git a/client/src/locales/en.json b/client/src/locales/en.json
index a1696eaff..9d7ce566a 100644
--- a/client/src/locales/en.json
+++ b/client/src/locales/en.json
@@ -106,6 +106,8 @@
"login": {
"register": "Register",
"login": "Login",
+ "or": "OR",
+ "loginWith": "Login via OIDC",
"forgotPassword": "Forgot password?",
"forgotPasswordNote1": "Note that password recovery by mail is only possible if you configured an email-address for your account.",
"forgotPasswordNote2": "If you did not do this, or are not sure, please contact the administrator of this server.",
diff --git a/client/src/locales/es.json b/client/src/locales/es.json
index 941203d55..a0ec0b747 100644
--- a/client/src/locales/es.json
+++ b/client/src/locales/es.json
@@ -74,7 +74,8 @@
"auth": {
"login": {
"register": "Registrarse",
- "login": "Iniciar Sesión"
+ "login": "Iniciar Sesión",
+ "loginWith": "Iniciar sesión via OIDC"
}
},
"core": {
diff --git a/client/src/locales/it.json b/client/src/locales/it.json
index 628f231fe..1e9d99660 100644
--- a/client/src/locales/it.json
+++ b/client/src/locales/it.json
@@ -74,7 +74,8 @@
"auth": {
"login": {
"register": "Registrati",
- "login": "Login"
+ "login": "Login",
+ "loginWith": "Accedi tramite OIDC"
}
},
"core": {
diff --git a/client/src/router/bootstrap.ts b/client/src/router/bootstrap.ts
index 94f1e27b1..d55eff6d9 100644
--- a/client/src/router/bootstrap.ts
+++ b/client/src/router/bootstrap.ts
@@ -1,16 +1,11 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import type { RouteRecordRaw } from "vue-router";
-import { AdminSection } from "../admin/types";
import { Logout } from "../auth/logout";
import Invitation from "../invitation";
import { router } from ".";
-// Admin
-const Admin = () => import("../admin/Admin.vue");
-const AdminUsers = () => import("../admin/Users.vue");
-const AdminCampaigns = () => import("../admin/Campaigns.vue");
// Auth
const Login = () => import("../auth/Login.vue");
// Dashboard
@@ -42,6 +37,12 @@ const routes: RouteRecordRaw[] = [
path: "logout",
component: Logout,
},
+ {
+ path: "callback",
+ component : Login,
+ name: "auth-callback",
+ }
+
],
},
{
diff --git a/client/src/router/index.ts b/client/src/router/index.ts
index 2b23af994..13b737919 100644
--- a/client/src/router/index.ts
+++ b/client/src/router/index.ts
@@ -32,12 +32,21 @@ router.beforeEach(async (to, _from, next) => {
// Handle core requests
const [authResponse, versionResponse] = await Promise.all(promiseArray);
- if (authResponse!.ok && versionResponse!.ok) {
+
+ // Check version response first (this should always succeed)
+ if (!versionResponse!.ok) {
+ console.error("Version check failed.");
+ checkLogin(next, to);
+ return;
+ }
+
+ const versionData = (await versionResponse!.json()) as { release: string; env: string };
+ coreStore.setVersion(versionData);
+
+ // Handle auth response (401 is expected for non-authenticated users)
+ if (authResponse!.ok) {
const authData = (await authResponse!.json()) as { auth: boolean; username: string; email: string };
- const versionData = (await versionResponse!.json()) as { release: string; env: string };
-
coreStore.setAuthenticated(authData.auth);
- coreStore.setVersion(versionData);
coreStore.setInitialized(true);
if (authData.auth) {
@@ -47,8 +56,15 @@ router.beforeEach(async (to, _from, next) => {
} else {
checkLogin(next, to);
}
+ } else if (authResponse!.status === 401) {
+ // 401 is expected for non-authenticated users - not an error
+ coreStore.setAuthenticated(false);
+ coreStore.setInitialized(true);
+ checkLogin(next, to);
} else {
- console.error("Authentication check could not be fulfilled.");
+ // Other auth errors (500, network issues, etc.)
+ console.error("Authentication check failed with status:", authResponse!.status);
+ coreStore.setInitialized(true);
checkLogin(next, to);
}
} else {
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 000000000..dffb8acdb
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,39 @@
+version: '3.8'
+
+services:
+ planarally:
+ image: kruptein/planarally:v2025.2.2
+ container_name: planarally
+ ports:
+ - "8000:8000"
+ volumes:
+ - planarally_data:/planarally/data
+ - planarally_assets:/planarally/static/assets
+ env_file:
+ - .env
+ environment:
+ # Authentication control
+ - PA_USERNAME_PASS=${PA_USERNAME_PASS}
+ - PA_ALLOW_SIGNUPS=${PA_ALLOW_SIGNUPS}
+ # Ensure these critical OIDC variables are set
+ - PA_OIDC_ENABLED=${PA_OIDC_ENABLED:-false}
+ - PA_OIDC_DOMAIN=${PA_OIDC_DOMAIN}
+ - PA_OIDC_CLIENT_ID=${PA_OIDC_CLIENT_ID}
+ - PA_OIDC_CLIENT_SECRET=${PA_OIDC_CLIENT_SECRET}
+ - PA_OIDC_AUTHORIZE_URL=${PA_OIDC_AUTHORIZE_URL}
+ - PA_OIDC_TOKEN_URL=${PA_OIDC_TOKEN_URL}
+ - PA_OIDC_USERINFO_URL=${PA_OIDC_USERINFO_URL}
+ - PA_OIDC_PROVIDER_NAME=${PA_OIDC_PROVIDER_NAME:-OIDC}
+ - PA_OIDC_USERNAME_FIELD=${PA_OIDC_USERNAME_FIELD}
+ - PA_OIDC_SCOPE=${PA_OIDC_SCOPE}
+ restart: unless-stopped
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:8000/api/version"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+ start_period: 40s
+
+volumes:
+ planarally_data:
+ planarally_assets:
\ No newline at end of file
diff --git a/server/pyproject.toml b/server/pyproject.toml
index 3aac39963..04b2de6a9 100644
--- a/server/pyproject.toml
+++ b/server/pyproject.toml
@@ -9,17 +9,20 @@ dependencies = [
"aiohttp==3.12.15",
"aiohttp-security==0.5.0",
"aiohttp-session==2.12.1",
+ "authlib==1.2.1",
"bcrypt==4.3.0",
"cryptography==45.0.7",
"peewee==3.18.2",
"pillow==11.3.0",
"pydantic[email]==1.10.18",
"python-engineio==4.12.2",
+ "python-dotenv>=1.0.0",
"python-socketio==5.13.0",
"redmail==0.6.0",
"rtoml==0.12.0",
"typing-extensions==4.15.0",
"watchdog==6.0.0",
+ "requests>=2.32.5",
]
[dependency-groups]
diff --git a/server/src/api/http/auth.py b/server/src/api/http/auth.py
index 12887cb0f..722da96f9 100644
--- a/server/src/api/http/auth.py
+++ b/server/src/api/http/auth.py
@@ -6,6 +6,34 @@
from ... import stats
from ...auth import get_authorized_user
+try:
+ from ...auth.oidc import oidc_auth
+except ImportError as e:
+ import logging
+ logger = logging.getLogger("PlanarAllyServer")
+ logger.warning(f"Failed to import OIDC module: {e}. OIDC authentication will be disabled.")
+
+ #stub for oidc_auth if the module is not available
+ class _OIDCStub:
+ def is_enabled(self):
+ return False
+
+ async def get_authorization_url(self, redirect_uri, state):
+ return None
+
+ async def exchange_code_for_token(self, code, redirect_uri):
+ return None
+
+ async def get_user_info(self, token):
+ return None
+
+ async def get_or_create_user(self, user_info):
+ return None
+
+ async def validate_token(self, token):
+ return None
+
+ oidc_auth = _OIDCStub()
from ...config import cfg
from ...db.db import db
from ...db.models.user import User
@@ -123,3 +151,147 @@ async def reset_password(request):
user.save()
return web.HTTPOk()
+
+# OIDC Authentication endpoints
+async def check_oidc_config(request):
+ """Check OIDC configuration status"""
+ import logging
+ logger = logging.getLogger("PlanarAllyServer")
+
+ try:
+ config = cfg()
+
+ # Debug logging to see what's being loaded
+ # Skip raw config debug in production
+
+ oidc_config_data = {
+ "oidc_enabled": config.oidc.enabled,
+ "oidc_domain": config.oidc.domain if config.oidc.enabled else None,
+ "oidc_client_id": config.oidc.client_id if config.oidc.enabled else None,
+ "oidc_provider_name": config.oidc.provider_name if config.oidc.enabled else None
+ }
+
+ # Skip config response debug in production
+
+ return web.json_response(oidc_config_data)
+ except Exception as e:
+ logger.error(f"Error checking OIDC config: {e}")
+ return web.json_response({"error": "Configuration check failed"}, status=500)
+
+
+async def oidc_login(request):
+ """Initiate OIDC login flow"""
+ import logging
+ logger = logging.getLogger("PlanarAllyServer")
+
+ if not oidc_auth.is_enabled():
+ logger.warning("OIDC login attempted but OIDC is not enabled")
+ return web.HTTPNotFound(reason="OIDC is not enabled")
+
+ try:
+ data = await request.json()
+ except Exception as e:
+ logger.error(f"Failed to parse OIDC login request: {e}")
+ return web.HTTPBadRequest(reason="Invalid JSON in request body")
+
+ redirect_uri = data.get("redirect_uri")
+ state = data.get("state")
+
+ if not redirect_uri or not state:
+ logger.error(f"Missing OIDC login parameters: redirect_uri={redirect_uri}, state={state}")
+ return web.HTTPBadRequest(reason="Missing redirect_uri or state")
+
+ auth_url = await oidc_auth.get_authorization_url(redirect_uri, state)
+ if not auth_url:
+ logger.error("Failed to generate OIDC authorization URL")
+ return web.HTTPInternalServerError(reason="Failed to generate authorization URL")
+
+ return web.json_response({"auth_url": auth_url})
+
+
+async def oidc_callback(request):
+ """Handle OIDC callback"""
+ import logging
+ logger = logging.getLogger("PlanarAllyServer")
+
+ if not oidc_auth.is_enabled():
+ logger.warning("OIDC callback attempted but OIDC is not enabled")
+ return web.HTTPNotFound(reason="OIDC is not enabled")
+
+ try:
+ data = await request.json()
+ except Exception as e:
+ logger.error(f"Failed to parse OIDC callback request: {e}")
+ return web.HTTPBadRequest(reason="Invalid JSON in request body")
+
+ code = data.get("code")
+ redirect_uri = data.get("redirect_uri")
+
+ if not code or not redirect_uri:
+ logger.error(f"Missing OIDC callback parameters: code={bool(code)}, redirect_uri={redirect_uri}")
+ return web.HTTPBadRequest(reason="Missing code or redirect_uri")
+
+ # Exchange code for token
+ token = await oidc_auth.exchange_code_for_token(code, redirect_uri)
+ if not token:
+ logger.error("Failed to exchange OIDC code for token")
+ return web.HTTPUnauthorized(reason="Failed to exchange code for token")
+
+ # Get user info
+ user_info = await oidc_auth.get_user_info(token)
+ if not user_info:
+ logger.error("Failed to get user information from OIDC token")
+ return web.HTTPUnauthorized(reason="Failed to get user information")
+
+ # Get or create user
+ user = await oidc_auth.get_or_create_user(user_info)
+ if not user:
+ logger.error("Failed to create or find user from OIDC user info")
+ return web.HTTPInternalServerError(reason="Failed to create or find user")
+
+ # Create session
+ response = web.json_response({"username": user.name, "email": user.email})
+ await remember(request, response, user.name)
+
+ return response
+
+
+async def oidc_config(request):
+ """Debug endpoint to check OIDC configuration"""
+ config = cfg()
+ return web.json_response({
+ "oidc_enabled": config.oidc.enabled,
+ "oidc_domain": config.oidc.domain,
+ "oidc_client_id": config.oidc.client_id,
+ "oidc_client_secret_set": bool(config.oidc.client_secret),
+ "oidc_audience": config.oidc.audience,
+ "oidc_provider_name": config.oidc.provider_name,
+ "oidc_auth_is_enabled": oidc_auth.is_enabled()
+ })
+
+
+async def oidc_validate(request):
+ """Validate OIDC token"""
+ if not oidc_auth.is_enabled():
+ return web.HTTPNotFound()
+
+ data = await request.json()
+ token = data.get("token")
+
+ if not token:
+ return web.HTTPBadRequest(reason="Missing token")
+
+ user_info = await oidc_auth.validate_token(token)
+ if not user_info:
+ return web.HTTPUnauthorized(reason="Invalid token")
+
+ # Get or create user
+ user = await oidc_auth.get_or_create_user(user_info)
+ if not user:
+ return web.HTTPInternalServerError(reason="Failed to create or find user")
+
+ return web.json_response({
+ "auth": True,
+ "username": user.name,
+ "email": user.email
+ })
diff --git a/server/src/app.py b/server/src/app.py
index 328879230..b2e995861 100644
--- a/server/src/app.py
+++ b/server/src/app.py
@@ -9,7 +9,7 @@
from . import auth
from .config import cfg
-from .json import PydanticJson
+from .pydantic_json import PydanticJson
from .logs import handle_async_exception
from .typed import TypedAsyncServer
diff --git a/server/src/auth.py b/server/src/auth.py
index d049b89e4..0b2c41730 100644
--- a/server/src/auth.py
+++ b/server/src/auth.py
@@ -1,76 +1,5 @@
-import logging
-from functools import wraps
-from typing import Union
+# This module is kept for backward compatibility
+# All auth functionality is now in the auth package
+from .auth.core import get_authorized_user, AuthPolicy, login_required, get_secret_token, get_api_token, token_middleware
-from aiohttp import web
-from aiohttp_security import authorized_userid
-from aiohttp_security.abc import AbstractAuthorizationPolicy
-from typing_extensions import Literal
-
-from .db.models.constants import Constants
-from .db.models.user import User
-
-logger = logging.getLogger("PlanarAllyServer")
-
-
-async def get_authorized_user(request: web.Request):
- if username := await authorized_userid(request):
- if user := User.by_name(username):
- return user
- raise web.HTTPUnauthorized()
-
-
-class AuthPolicy(AbstractAuthorizationPolicy):
- async def authorized_userid(self, identity):
- """Retrieve authorized user id.
- Return the user_id of the user identified by the identity
- or 'None' if no user exists related to the identity.
- """
- return identity
-
- async def permits(self, _identity, _permission, _context=None):
- """Check user permissions.
- Return True if the identity is allowed the permission in the
- current context, else return False.
- """
- return False
-
-
-def login_required(app, sio, state: Union[Literal["game"], Literal["asset"], Literal["dashboard"]]):
- """
- Decorator that restrict access only for authorized users in a websocket context.
- """
-
- def real_decorator(fn):
- @wraps(fn)
- async def wrapped(*args, **kwargs):
- sid = args[0]
- if not app["state"]["asset"].has_sid(sid) and not app["state"][state].has_sid(sid):
- await sio.emit("redirect", "/")
- return
- return await fn(*args, **kwargs)
-
- return wrapped
-
- return real_decorator
-
-
-def get_secret_token():
- return Constants.get().secret_token
-
-
-def get_api_token():
- return Constants.get().api_token
-
-
-@web.middleware
-async def token_middleware(request: web.Request, handler):
- try:
- authorization = request.headers["Authorization"]
- except KeyError:
- raise web.HTTPUnauthorized(reason="Missing authorization header")
-
- if authorization != f"Bearer {get_api_token()}":
- raise web.HTTPForbidden(reason="Invalid authorization header.")
-
- return await handler(request)
+__all__ = ['get_authorized_user', 'AuthPolicy', 'login_required', 'get_secret_token', 'get_api_token', 'token_middleware']
diff --git a/server/src/auth/__init__.py b/server/src/auth/__init__.py
new file mode 100644
index 000000000..6170daddc
--- /dev/null
+++ b/server/src/auth/__init__.py
@@ -0,0 +1,5 @@
+# Auth module initialization
+# Import and re-export the main auth functions from the core module
+from .core import get_authorized_user, AuthPolicy, login_required, get_secret_token, get_api_token, token_middleware
+
+__all__ = ['get_authorized_user', 'AuthPolicy', 'login_required', 'get_secret_token', 'get_api_token', 'token_middleware']
diff --git a/server/src/auth/core.py b/server/src/auth/core.py
new file mode 100644
index 000000000..9e9c3fde9
--- /dev/null
+++ b/server/src/auth/core.py
@@ -0,0 +1,88 @@
+import logging
+from functools import wraps
+from typing import Union
+
+from aiohttp import web
+from aiohttp_security import authorized_userid
+from aiohttp_security.abc import AbstractAuthorizationPolicy
+from typing_extensions import Literal
+
+from ..db.models.constants import Constants
+from ..db.models.user import User
+
+logger = logging.getLogger("PlanarAllyServer")
+
+
+async def get_authorized_user(request: web.Request):
+ if username := await authorized_userid(request):
+ if user := User.by_name(username):
+ return user
+ raise web.HTTPUnauthorized()
+
+
+class AuthPolicy(AbstractAuthorizationPolicy):
+ async def authorized_userid(self, identity):
+ """Retrieve authorized user id.
+ Return the user_id of the user identified by the identity
+ or 'None' if no user exists related to the identity.
+ """
+ return identity
+
+ async def permits(self, identity, permission, context=None):
+ """Check user permissions.
+ Return True if the identity is allowed the permission in the
+ current context, else return False.
+ """
+ user: Union[User, None] = User.by_name(identity) if identity else None
+ if user is None:
+ return False
+
+ if permission == "lobby.read":
+ if user.role != Constants.role.DM:
+ return user.can_play_game(context["room"])
+ return True
+ elif permission == "dm":
+ return user.role == Constants.role.DM
+ elif permission == "token.read":
+ return user.role == Constants.role.DM
+ return False
+
+
+def login_required(app, sio, state: Union[Literal["game"], Literal["asset"], Literal["dashboard"]]):
+ """
+ Decorator that restrict access only for authorized users in a websocket context.
+ """
+
+ def real_decorator(fn):
+ @wraps(fn)
+ async def wrapped(*args, **kwargs):
+ sid = args[0]
+ if not app["state"]["asset"].has_sid(sid) and not app["state"][state].has_sid(sid):
+ await sio.emit("redirect", "/")
+ return
+ return await fn(*args, **kwargs)
+
+ return wrapped
+
+ return real_decorator
+
+
+def get_secret_token():
+ return Constants.get().secret_token
+
+
+def get_api_token():
+ return Constants.get().api_token
+
+
+@web.middleware
+async def token_middleware(request: web.Request, handler):
+ try:
+ authorization = request.headers["Authorization"]
+ except KeyError:
+ raise web.HTTPUnauthorized(reason="Missing authorization header")
+
+ if authorization != f"Bearer {get_api_token()}":
+ raise web.HTTPForbidden(reason="Invalid authorization header.")
+
+ return await handler(request)
diff --git a/server/src/auth/oidc.py b/server/src/auth/oidc.py
new file mode 100644
index 000000000..ea689ca4d
--- /dev/null
+++ b/server/src/auth/oidc.py
@@ -0,0 +1,306 @@
+import logging
+from typing import Optional
+import aiohttp
+import urllib.parse
+
+from ..config import cfg
+from ..db.models.user import User
+
+logger = logging.getLogger("PlanarAllyServer")
+
+
+class OIDCAuth:
+ def __init__(self):
+ self.config = cfg().oidc
+ self.client_id = None
+ self.client_secret = None
+ self.scope = self.config.scope
+ self._discovery_cache: Optional[dict] = None
+ self._setup_client()
+
+ def _setup_client(self):
+ """Setup OAuth2 client if OIDC is enabled"""
+ if not self.config.enabled or not all([
+ self.config.domain,
+ self.config.client_id,
+ self.config.client_secret
+ ]):
+ return
+
+ try:
+ self.client_id = self.config.client_id
+ self.client_secret = self.config.client_secret
+ # Client initialization successful - no need to log in production
+ except Exception as e:
+ logger.error(f"Failed to initialize OIDC client: {e}")
+ self.client_id = None
+ self.client_secret = None
+
+ async def _get_discovery_document(self) -> Optional[dict]:
+ """Get OIDC discovery document from provider"""
+ if self._discovery_cache:
+ return self._discovery_cache
+
+ if not self.config.domain:
+ return None
+
+ try:
+ # Enforce HTTPS for security - only allow secure protocols
+ domain = self.config.domain
+ if '://' in domain and not domain.startswith('https://'):
+ # Reject any non-HTTPS protocol
+ logger.error(f"Insecure protocol rejected for OIDC domain: {domain}. Only HTTPS is allowed.")
+ return None
+ elif not domain.startswith('https://'):
+ # Add HTTPS if no protocol specified
+ domain = f"https://{domain}"
+
+ discovery_url = f"{domain}/.well-known/openid-configuration"
+
+ async with aiohttp.ClientSession() as session:
+ async with session.get(discovery_url) as response:
+ if response.status == 200:
+ self._discovery_cache = await response.json()
+ return self._discovery_cache
+ else:
+ logger.error(f"Failed to get OIDC discovery document: {response.status}")
+ return None
+ except Exception as e:
+ logger.error(f"Failed to retrieve OIDC discovery document: {e}")
+ return None
+
+ def is_enabled(self) -> bool:
+ """Check if OIDC authentication is enabled and properly configured"""
+ enabled = (
+ self.config.enabled and
+ self.client_id is not None and
+ all([self.config.domain, self.config.client_id, self.config.client_secret])
+ )
+
+ # Skip enabled check logging in production
+
+ return enabled
+
+ async def get_authorization_url(self, redirect_uri: str, state: str) -> Optional[str]:
+ """Get the authorization URL for OIDC login"""
+ if not self.is_enabled():
+ return None
+
+ # Use direct URL if provided, otherwise use discovery
+ if self.config.authorize_url:
+ authorization_endpoint = self.config.authorize_url
+ logger.debug("Using direct authorize URL") if logger.isEnabledFor(logging.DEBUG) else None
+ else:
+ discovery = await self._get_discovery_document()
+ if not discovery:
+ return None
+ authorization_endpoint = discovery.get("authorization_endpoint")
+ if not authorization_endpoint:
+ logger.error("No authorization_endpoint found in discovery document")
+ return None
+
+ try:
+
+ # Build authorization URL parameters
+ params = {
+ "client_id": self.client_id,
+ "redirect_uri": redirect_uri,
+ "state": state,
+ "response_type": "code",
+ "scope": self.scope,
+ }
+
+ # Add audience if configured (provider-specific)
+ if self.config.audience:
+ params["audience"] = self.config.audience
+
+ # Build the authorization URL manually
+ query_string = urllib.parse.urlencode(params)
+ auth_url = f"{authorization_endpoint}?{query_string}"
+ return auth_url
+ except Exception as e:
+ logger.error(f"Failed to generate authorization URL: {e}")
+ return None
+
+ async def exchange_code_for_token(self, code: str, redirect_uri: str) -> Optional[dict]:
+ """Exchange authorization code for access token"""
+ if not self.is_enabled():
+ return None
+
+ # Use direct URL if provided, otherwise use discovery
+ if self.config.token_url:
+ token_endpoint = self.config.token_url
+ logger.debug("Using direct token URL") if logger.isEnabledFor(logging.DEBUG) else None
+ else:
+ discovery = await self._get_discovery_document()
+ if not discovery:
+ return None
+ token_endpoint = discovery.get("token_endpoint")
+ if not token_endpoint:
+ logger.error("No token_endpoint found in discovery document")
+ return None
+
+ try:
+
+ # Prepare token request data
+ token_data = {
+ "grant_type": "authorization_code",
+ "code": code,
+ "redirect_uri": redirect_uri,
+ "client_id": self.client_id,
+ "client_secret": self.client_secret,
+ }
+
+ # Add audience if configured
+ if self.config.audience:
+ token_data["audience"] = self.config.audience
+
+ # Make token request using aiohttp
+ async with aiohttp.ClientSession() as session:
+ async with session.post(token_endpoint, data=token_data) as response:
+ if response.status == 200:
+ token_json = await response.json()
+ return token_json
+ else:
+ error_text = await response.text()
+ logger.error(f"Token exchange failed: {response.status} - {error_text}")
+ return None
+ except Exception as e:
+ logger.error(f"Failed to exchange code for token: {e}")
+ return None
+
+ async def get_user_info(self, token: dict) -> Optional[dict]:
+ """Get user information from the OIDC provider"""
+ if not self.is_enabled() or not token:
+ return None
+
+ # Use direct URL if provided, otherwise use discovery
+ if self.config.userinfo_url:
+ userinfo_endpoint = self.config.userinfo_url
+ logger.debug("Using direct userinfo URL") if logger.isEnabledFor(logging.DEBUG) else None
+ else:
+ discovery = await self._get_discovery_document()
+ if not discovery:
+ return None
+ userinfo_endpoint = discovery.get("userinfo_endpoint")
+ if not userinfo_endpoint:
+ logger.error("No userinfo_endpoint found in discovery document")
+ return None
+
+ try:
+
+ async with aiohttp.ClientSession() as session:
+ headers = {"Authorization": f"Bearer {token['access_token']}"}
+ async with session.get(userinfo_endpoint, headers=headers) as response:
+ if response.status == 200:
+ return await response.json()
+ else:
+ logger.error(f"Failed to get user info: {response.status}")
+ return None
+ except Exception as e:
+ logger.error(f"Failed to get user info: {e}")
+ return None
+
+ async def get_or_create_user(self, user_info: dict) -> Optional[User]:
+ """Get existing user or create new user from OIDC user info"""
+ if not user_info:
+ return None
+
+ # Extract user information with fallbacks
+ email = user_info.get("email")
+
+ # Get username from configured field first, then fallback to others
+ configured_field = self.config.username_field
+ name = user_info.get(configured_field)
+
+ # If configured field is not available, try other common fields
+ if not name:
+ name = (user_info.get("preferred_username") or
+ user_info.get("name") or
+ user_info.get("nickname") or
+ user_info.get("given_name") or
+ email.split("@")[0] if email else None)
+
+ if not name:
+ logger.error("No valid username found in OIDC user info")
+ return None
+
+ # Try to find existing user by email first, then by name
+ user = None
+ if email:
+ user = User.by_email(email)
+
+ if not user:
+ user = User.by_name(name)
+
+ # Create new user if not found
+ if not user:
+ try:
+ # Check if signups are allowed
+ from ..config import cfg
+ if not cfg().general.allow_signups:
+ logger.warning(f"OIDC user creation blocked - signups disabled: {name}")
+ return None
+
+ # Create user with a secure random password
+ import secrets
+ random_password = secrets.token_urlsafe(32)
+ user = User.create_new(name, random_password, email)
+ except Exception as e:
+ logger.error(f"Failed to create OIDC user: {e}")
+ return None
+
+ # Update email if it's different and not None
+ if email and user.email != email:
+ try:
+ user.email = email
+ user.save()
+ logger.info(f"Updated email for user {name}")
+ except Exception as e:
+ logger.error(f"Failed to update email for user {name}: {e}")
+
+ return user
+
+ async def validate_token(self, token: str) -> Optional[dict]:
+ """Validate an access token and return user info"""
+ if not self.is_enabled():
+ return None
+
+ # Use direct URL if provided, otherwise use discovery
+ if self.config.userinfo_url:
+ userinfo_endpoint = self.config.userinfo_url
+ logger.info(f"Using direct userinfo URL for validation: {userinfo_endpoint}")
+ else:
+ discovery = await self._get_discovery_document()
+ if not discovery:
+ return None
+ userinfo_endpoint = discovery.get("userinfo_endpoint")
+ if not userinfo_endpoint:
+ logger.error("No userinfo_endpoint found in discovery document")
+ return None
+
+ try:
+
+ async with aiohttp.ClientSession() as session:
+ headers = {"Authorization": f"Bearer {token}"}
+ async with session.get(userinfo_endpoint, headers=headers) as response:
+ if response.status == 200:
+ return await response.json()
+ else:
+ logger.error(f"Token validation failed: {response.status}")
+ return None
+ except Exception as e:
+ logger.error(f"Token validation failed: {e}")
+ return None
+
+ def get_provider_info(self) -> dict:
+ """Get provider information for client-side display"""
+ return {
+ "name": self.config.provider_name,
+ "enabled": self.is_enabled(),
+ "domain": self.config.domain
+ }
+
+
+# Global OIDC instance
+oidc_auth = OIDCAuth()
\ No newline at end of file
diff --git a/server/src/config/manager.py b/server/src/config/manager.py
index 943159c7d..3fd9d185a 100644
--- a/server/src/config/manager.py
+++ b/server/src/config/manager.py
@@ -1,10 +1,11 @@
import os
import sys
-from datetime import UTC, datetime
+from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable, Dict
import rtoml
+from dotenv import load_dotenv
from pydantic import ValidationError
from watchdog.events import FileSystemEventHandler
from watchdog.observers import Observer
@@ -21,7 +22,7 @@ def __init__(self, callback: Callable[[], Any]):
def on_modified(self, event):
src = os.fsdecode(event.src_path)
if src.endswith("config.toml"):
- current_time = datetime.now(UTC).timestamp()
+ current_time = datetime.now(timezone.utc).timestamp()
if current_time - self._last_modified > 1:
self._last_modified = current_time
self.callback()
@@ -33,6 +34,13 @@ def __init__(self, config_path: Path):
self.config = ServerConfig()
self._file_observer = Observer()
+ # Load .env file if it exists (check both current dir and parent dir)
+ env_paths = [Path(".env"), Path("../.env")]
+ for env_path in env_paths:
+ if env_path.exists():
+ load_dotenv(env_path)
+ break
+
self.load_config(startup=True)
# Setup file watching
@@ -41,19 +49,28 @@ def __init__(self, config_path: Path):
self._file_observer.start()
def load_config(self, *, startup=False) -> None:
- """Load configuration from file"""
+ """Load configuration from file and environment variables"""
try:
+ config_data = {}
+
+ # Load from file if it exists
if self.config_path.exists():
config_data = rtoml.loads(self.config_path.read_text())
- self.config = ServerConfig(**config_data)
- set_save_path(self.config.general.save_file)
-
- if not startup:
- from ..logs import logger
- from ..mail import reset_email
-
- logger.info("Config file changed, reloading")
- reset_email()
+
+ # Override with environment variables
+ env_overrides = self._load_env_overrides()
+ if env_overrides:
+ config_data = self._deep_merge(config_data, env_overrides)
+
+ self.config = ServerConfig(**config_data)
+ set_save_path(self.config.general.save_file)
+
+ if not startup:
+ from ..logs import logger
+ from ..mail import reset_email
+
+ logger.info("Config file changed, reloading")
+ reset_email()
except rtoml.TomlParsingError as e:
print(f"Error loading config: {e}")
except ValidationError as e:
@@ -61,6 +78,80 @@ def load_config(self, *, startup=False) -> None:
if startup:
sys.exit(1)
+ def _load_env_overrides(self) -> Dict[str, Any]:
+ """Load configuration overrides from environment variables"""
+ env_config = {}
+
+ # General configuration
+ if val := os.getenv("PA_CLIENT_URL"):
+ env_config.setdefault("general", {})["client_url"] = val
+ if val := os.getenv("PA_ALLOW_SIGNUPS"):
+ env_config.setdefault("general", {})["allow_signups"] = val.lower() in ("true", "1", "yes")
+ if val := os.getenv("PA_USERNAME_PASS"):
+ env_config.setdefault("general", {})["username_password_enabled"] = val.lower() in ("true", "1", "yes")
+ if val := os.getenv("PA_ENABLE_EXPORT"):
+ env_config.setdefault("general", {})["enable_export"] = val.lower() in ("true", "1", "yes")
+ if val := os.getenv("PA_ADMIN_USER"):
+ env_config.setdefault("general", {})["admin_user"] = val
+
+ # Webserver configuration
+ if val := os.getenv("PA_WEBSERVER_HOST"):
+ env_config.setdefault("webserver", {}).setdefault("connection", {})["host"] = val
+ if val := os.getenv("PA_WEBSERVER_PORT"):
+ env_config.setdefault("webserver", {}).setdefault("connection", {})["port"] = int(val)
+ if val := os.getenv("PA_CORS_ALLOWED_ORIGINS"):
+ env_config.setdefault("webserver", {})["cors_allowed_origins"] = val
+ if val := os.getenv("PA_MAX_UPLOAD_SIZE_IN_BYTES"):
+ env_config.setdefault("webserver", {})["max_upload_size_in_bytes"] = int(val)
+
+ # OIDC configuration
+ if val := os.getenv("PA_OIDC_ENABLED"):
+ env_config.setdefault("oidc", {})["enabled"] = val.lower() in ("true", "1", "yes")
+ if val := os.getenv("PA_OIDC_DOMAIN"):
+ env_config.setdefault("oidc", {})["domain"] = val
+ if val := os.getenv("PA_OIDC_CLIENT_ID"):
+ env_config.setdefault("oidc", {})["client_id"] = val
+ if val := os.getenv("PA_OIDC_CLIENT_SECRET"):
+ env_config.setdefault("oidc", {})["client_secret"] = val
+ if val := os.getenv("PA_OIDC_AUDIENCE"):
+ env_config.setdefault("oidc", {})["audience"] = val
+ if val := os.getenv("PA_OIDC_PROVIDER_NAME"):
+ env_config.setdefault("oidc", {})["provider_name"] = val
+ if val := os.getenv("PA_OIDC_USERNAME_FIELD"):
+ env_config.setdefault("oidc", {})["username_field"] = val
+ if val := os.getenv("PA_OIDC_SCOPE"):
+ env_config.setdefault("oidc", {})["scope"] = val
+ # Direct URL overrides (bypass discovery)
+ if val := os.getenv("PA_OIDC_AUTHORIZE_URL"):
+ env_config.setdefault("oidc", {})["authorize_url"] = val
+ if val := os.getenv("PA_OIDC_TOKEN_URL"):
+ env_config.setdefault("oidc", {})["token_url"] = val
+ if val := os.getenv("PA_OIDC_USERINFO_URL"):
+ env_config.setdefault("oidc", {})["userinfo_url"] = val
+
+
+ # Stats configuration
+ if val := os.getenv("PA_STATS_ENABLED"):
+ env_config.setdefault("stats", {})["enabled"] = val.lower() in ("true", "1", "yes")
+ if val := os.getenv("PA_STATS_ENABLE_EXPORT"):
+ env_config.setdefault("stats", {})["enable_export"] = val.lower() in ("true", "1", "yes")
+ if val := os.getenv("PA_STATS_EXPORT_FREQUENCY_IN_SECONDS"):
+ env_config.setdefault("stats", {})["export_frequency_in_seconds"] = int(val)
+ if val := os.getenv("PA_STATS_URL"):
+ env_config.setdefault("stats", {})["stats_url"] = val
+
+ return env_config
+
+ def _deep_merge(self, base: Dict[str, Any], updates: Dict[str, Any]) -> Dict[str, Any]:
+ """Deep merge two dictionaries"""
+ result = base.copy()
+ for key, value in updates.items():
+ if key in result and isinstance(result[key], dict) and isinstance(value, dict):
+ result[key] = self._deep_merge(result[key], value)
+ else:
+ result[key] = value
+ return result
+
def save_config(self) -> None:
"""Save current config to file (debounced)"""
from ..logs import logger
@@ -68,7 +159,7 @@ def save_config(self) -> None:
logger.info("Saving config")
try:
config_dict = self.config.dict()
- update_time = datetime.now(UTC).isoformat()
+ update_time = datetime.now(timezone.utc).isoformat()
self.config_path.write_text(
f"# Last updated from UI at {update_time}\n{rtoml.dumps(config_dict, none_value=None)}"
)
diff --git a/server/src/config/types.py b/server/src/config/types.py
index 2de901ced..e4b32be08 100644
--- a/server/src/config/types.py
+++ b/server/src/config/types.py
@@ -1,25 +1,20 @@
from typing import Literal, Optional
-from pydantic import BaseModel, EmailStr, Extra
+from pydantic import BaseModel, EmailStr
-class ConfigModel(BaseModel):
- class Config:
- extra = Extra.forbid
-
-
-class HostPortConnection(ConfigModel):
+class HostPortConnection(BaseModel):
type: Literal["hostport"] = "hostport"
host: str = "0.0.0.0"
port: int = 8000
-class SocketConnection(ConfigModel):
+class SocketConnection(BaseModel):
type: Literal["socket"] = "socket"
socket: str
-class SslConfig(ConfigModel):
+class SslConfig(BaseModel):
# Can be used to disable SSL,
# without having to remove the ssl section entirely
enabled: bool = False
@@ -27,7 +22,7 @@ class SslConfig(ConfigModel):
privkey: str
-class WebserverConfig(ConfigModel):
+class WebserverConfig(BaseModel):
# Core connection string
# This is either a HOST:PORT combination
# or a UNIX socket path
@@ -52,7 +47,7 @@ class WebserverConfig(ConfigModel):
max_upload_size_in_bytes: int = 10_485_760
-class AssetsConfig(ConfigModel):
+class AssetsConfig(BaseModel):
# Can be used to signal that assets are stored in a different directory
directory: Optional[str] = None
@@ -67,7 +62,7 @@ class AssetsConfig(ConfigModel):
max_total_asset_size_in_bytes: int = 0
-class GeneralConfig(ConfigModel):
+class GeneralConfig(BaseModel):
# Location of the save file
# This is relative to the server root
save_file: str = "data/planar.sqlite"
@@ -84,6 +79,11 @@ class GeneralConfig(ConfigModel):
# Accounts can be created manually using the CLI by the admin
allow_signups: bool = True
+ # Enable username/password login
+ # If disabled, users can only login via OIDC
+ # This hides the traditional login form and register button
+ username_password_enabled: bool = True
+
# Enable exporting of campaigns
# If disabled, users will not be able to export campaigns
enable_export: bool = True
@@ -95,11 +95,10 @@ class GeneralConfig(ConfigModel):
admin_user: Optional[str] = None
-
-class MailConfig(ConfigModel):
+class MailConfig(BaseModel):
# Can be used to disable email functionality
# without having to remove the mail section entirely
- enabled: bool = True
+ enabled: bool = False
# HOST:PORT combination for the SMTP server
host: str
port: int
@@ -110,8 +109,32 @@ class MailConfig(ConfigModel):
default_from_address: EmailStr
ssl_mode: Literal["ssl", "tls", "starttls", "lmtp"] = "starttls"
-
-class StatsConfig(ConfigModel):
+class OidcConfig(BaseModel):
+ # Enable OIDC authentication
+ enabled: bool = False
+
+ # OIDC provider configuration
+ domain: Optional[str] = None
+ client_id: Optional[str] = None
+ client_secret: Optional[str] = None
+ audience: Optional[str] = None
+
+ # OIDC provider name for display
+ provider_name: str = "OIDC"
+
+ # Username field mapping from OIDC user info
+ username_field: str = "preferred_username"
+
+ # OIDC scopes to request (space-separated)
+ scope: str = "openid profile email"
+
+ # Direct URL overrides (bypass discovery)
+ authorize_url: Optional[str] = None
+ token_url: Optional[str] = None
+ userinfo_url: Optional[str] = None
+
+
+class StatsConfig(BaseModel):
# Enable collection of stats
enabled: bool = True
@@ -127,9 +150,14 @@ class StatsConfig(ConfigModel):
stats_url: str = "https://stats.planarally.io"
-class ServerConfig(ConfigModel):
+class ServerConfig(BaseModel):
general: GeneralConfig = GeneralConfig()
assets: AssetsConfig = AssetsConfig()
webserver: WebserverConfig = WebserverConfig()
stats: StatsConfig = StatsConfig()
mail: Optional[MailConfig] = None
+ # Optional API server configuration
+ # If not specified, the API server will not be started
+ # Note: ensure that a different connection string is used
+ apiserver: Optional[WebserverConfig] = None
+ oidc: OidcConfig = OidcConfig()
\ No newline at end of file
diff --git a/server/src/json.py b/server/src/json.py
index dd0bacc2e..d676a5513 100644
--- a/server/src/json.py
+++ b/server/src/json.py
@@ -1,6 +1,7 @@
-import json
+import importlib
-from pydantic import BaseModel
+# Import the standard json module by its full path to avoid circular import
+python_json = importlib.import_module('json')
class PydanticJson(object):
@@ -8,15 +9,17 @@ class PydanticJson(object):
def dumps(*args, **kwargs):
if "cls" not in kwargs:
kwargs["cls"] = PydanticEncoder
- return json.dumps(*args, **kwargs)
+ return python_json.dumps(*args, **kwargs)
@staticmethod
def loads(*args, **kwargs):
- return json.loads(*args, **kwargs)
+ return python_json.loads(*args, **kwargs)
-class PydanticEncoder(json.JSONEncoder):
+class PydanticEncoder(python_json.JSONEncoder):
def default(self, obj):
+ # Import pydantic here to avoid circular import
+ from pydantic import BaseModel
if isinstance(obj, BaseModel):
return obj.dict()
- return json.JSONEncoder.default(self, obj)
+ return python_json.JSONEncoder.default(self, obj)
diff --git a/server/src/mail/__init__.py b/server/src/mail/__init__.py
index f196d1e9b..8ece2bdbe 100644
--- a/server/src/mail/__init__.py
+++ b/server/src/mail/__init__.py
@@ -24,7 +24,7 @@ def get_email() -> EmailSender:
if _email is None:
config = cfg()
- if not config.mail or not config.mail.enabled:
+ if not config.mail.enabled:
raise ValueError("Mail is not enabled")
if not config.mail.host:
diff --git a/server/src/pydantic_json.py b/server/src/pydantic_json.py
new file mode 100644
index 000000000..dd0bacc2e
--- /dev/null
+++ b/server/src/pydantic_json.py
@@ -0,0 +1,22 @@
+import json
+
+from pydantic import BaseModel
+
+
+class PydanticJson(object):
+ @staticmethod
+ def dumps(*args, **kwargs):
+ if "cls" not in kwargs:
+ kwargs["cls"] = PydanticEncoder
+ return json.dumps(*args, **kwargs)
+
+ @staticmethod
+ def loads(*args, **kwargs):
+ return json.loads(*args, **kwargs)
+
+
+class PydanticEncoder(json.JSONEncoder):
+ def default(self, obj):
+ if isinstance(obj, BaseModel):
+ return obj.dict()
+ return json.JSONEncoder.default(self, obj)
diff --git a/server/src/routes.py b/server/src/routes.py
index 7e34b618e..a2407419d 100644
--- a/server/src/routes.py
+++ b/server/src/routes.py
@@ -1,11 +1,15 @@
import os
import sys
+from functools import partial
import aiohttp
from aiohttp import web
from .api import http
from .api.http import auth, mods, notifications, rooms, server, users, version
+from .api.http.admin import campaigns
+from .api.http.admin import users as admin_users
+from .app import admin_app, api_app
from .app import app as main_app
from .config import cfg
from .utils import ASSETS_DIR, FILE_DIR, STATIC_DIR
@@ -18,22 +22,73 @@
def __replace_config_data(data: bytes) -> bytes:
config = cfg()
+ # SIGNUP CONFIGURATION
if not config.general.allow_signups:
data = data.replace(b'name="PA-signup" content="true"', b'name="PA-signup" content="false"')
- if not config.mail or not config.mail.enabled:
+
+ # OIDC CONFIGURATION - Check first to determine if it's available
+ oidc_fully_configured = (
+ config.oidc.enabled and
+ config.oidc.client_id and
+ config.oidc.client_secret and
+ bool(config.oidc.domain or config.oidc.authorize_url) # Either domain for discovery OR direct URLs
+ )
+
+ # USERNAME/PASSWORD CONFIGURATION with fallback safety
+ # If username/password is disabled BUT OIDC is not available, enable username/password as fallback
+ username_pass_should_be_disabled = (
+ not config.general.username_password_enabled and
+ oidc_fully_configured # Only disable if OIDC is actually available
+ )
+
+ if username_pass_should_be_disabled:
+ data = data.replace(b'name="PA-username-pass" content="true"', b'name="PA-username-pass" content="false"')
+ elif not config.general.username_password_enabled and not oidc_fully_configured:
+ # Fallback activated: username/password was disabled but OIDC is not available
+ # Keep username/password enabled to prevent users from being locked out
+ pass # Meta tag stays as content="true"
+
+ # MAIL CONFIGURATION
+ if config.mail is None or not config.mail.enabled or not config.mail.host or not config.mail.port or not config.mail.default_from_address:
data = data.replace(b'name="PA-mail" content="true"', b'name="PA-mail" content="false"')
+
+ if oidc_fully_configured:
+ # Enable OIDC
+ data = data.replace(b'name="PA-oidc" content="false"', b'name="PA-oidc" content="true"')
+
+ # Set domain (for discovery or display)
+ if config.oidc.domain:
+ domain = config.oidc.domain.replace('"', '"')
+ data = data.replace(b'name="PA-oidc-domain" content=""', f'name="PA-oidc-domain" content="{domain}"'.encode())
+
+ # Set client ID
+ if config.oidc.client_id:
+ client_id = config.oidc.client_id.replace('"', '"')
+ data = data.replace(b'name="PA-oidc-client-id" content=""', f'name="PA-oidc-client-id" content="{client_id}"'.encode())
+
+ # Set audience if present
+ if config.oidc.audience:
+ audience = config.oidc.audience.replace('"', '"')
+ data = data.replace(b'name="PA-oidc-audience" content=""', f'name="PA-oidc-audience" content="{audience}"'.encode())
+
+ # Set provider name
+ if config.oidc.provider_name:
+ provider = config.oidc.provider_name.replace('"', '"')
+ data = data.replace(b'name="PA-oidc-provider" content="OIDC"', f'name="PA-oidc-provider" content="{provider}"'.encode())
+
return data
-async def root(request):
- template = "index.html"
+
+async def root(request, admin_api=False):
+ template = "admin-index.html" if admin_api else "index.html"
with open(FILE_DIR / "templates" / template, "rb") as f:
data = __replace_config_data(f.read())
return web.Response(body=data, content_type="text/html")
-async def root_dev(request):
- port = 8080
+async def root_dev(request, admin_api=False):
+ port = 8081 if admin_api else 8080
target_url = f"http://localhost:{port}{request.rel_url}"
data = await request.read()
async with aiohttp.ClientSession() as client:
@@ -55,6 +110,10 @@ async def root_dev(request):
main_app.router.add_post(f"{subpath}/api/logout", auth.logout)
main_app.router.add_post(f"{subpath}/api/forgot-password", auth.forgot_password)
main_app.router.add_post(f"{subpath}/api/reset-password", auth.reset_password)
+main_app.router.add_post(f"{subpath}/api/oidc/login", auth.oidc_login)
+main_app.router.add_post(f"{subpath}/api/oidc/exchange", auth.oidc_callback)
+main_app.router.add_post(f"{subpath}/api/oidc/validate", auth.oidc_validate)
+main_app.router.add_get(f"{subpath}/api/oidc/config", auth.check_oidc_config) # Debug endpoint
main_app.router.add_get(f"{subpath}/api/server/upload_limit", server.get_limit)
main_app.router.add_get(f"{subpath}/api/rooms", rooms.get_list)
main_app.router.add_post(f"{subpath}/api/rooms", rooms.create)
@@ -71,8 +130,23 @@ async def root_dev(request):
main_app.router.add_get(f"{subpath}/api/notifications", notifications.collect)
main_app.router.add_post(f"{subpath}/api/mod/upload", mods.upload)
+# ADMIN ROUTES
+
+api_app.router.add_post(f"{subpath}/notifications", notifications.create)
+api_app.router.add_get(f"{subpath}/notifications", notifications.collect)
+api_app.router.add_delete(f"{subpath}/notifications/{{uuid}}", notifications.delete)
+api_app.router.add_get(f"{subpath}/users", admin_users.collect)
+api_app.router.add_post(f"{subpath}/users/reset", admin_users.reset)
+api_app.router.add_post(f"{subpath}/users/remove", admin_users.remove)
+api_app.router.add_get(f"{subpath}/campaigns", campaigns.collect)
+
+admin_app.router.add_static(f"{subpath}/static", STATIC_DIR)
+admin_app.add_subapp("/api/", api_app)
+
TAIL_REGEX = "/{tail:(?!api).*}"
if "dev" in sys.argv:
main_app.router.add_route("*", TAIL_REGEX, root_dev)
+ admin_app.router.add_route("*", TAIL_REGEX, partial(root_dev, admin_api=True))
else:
main_app.router.add_route("*", TAIL_REGEX, root)
+ admin_app.router.add_route("*", TAIL_REGEX, partial(root, admin_api=True))
diff --git a/server/uv.lock b/server/uv.lock
index 2c533e539..4c589a060 100644
--- a/server/uv.lock
+++ b/server/uv.lock
@@ -1,5 +1,5 @@
version = 1
-revision = 2
+revision = 3
requires-python = ">=3.13.2"
[[package]]
@@ -90,6 +90,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fc/30/d4986a882011f9df997a55e6becd864812ccfcd821d64aac8570ee39f719/attrs-25.1.0-py3-none-any.whl", hash = "sha256:c75a69e28a550a7e93789579c22aa26b0f5b83b75dc4e08fe092980051e1090a", size = 63152, upload-time = "2025-01-25T11:30:10.164Z" },
]
+[[package]]
+name = "authlib"
+version = "1.2.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cryptography" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/61/ab/7475c1292d0ae22e291d74468693ae29c146185859e3ea4e01a179e9b625/Authlib-1.2.1.tar.gz", hash = "sha256:421f7c6b468d907ca2d9afede256f068f87e34d23dd221c07d13d4c234726afb", size = 140416, upload-time = "2023-06-25T13:03:58.165Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/81/6e/f4522542322c7f53783da5f65464a7dee137c687111624d2ac733e2a1b98/Authlib-1.2.1-py2.py3-none-any.whl", hash = "sha256:c88984ea00149a90e3537c964327da930779afa4564e354edfd98410bea01911", size = 215252, upload-time = "2023-06-25T13:03:55.988Z" },
+]
+
[[package]]
name = "bcrypt"
version = "4.3.0"
@@ -149,6 +161,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl", hash = "sha256:5dae8d4d79b552a71cbabc7deb25dfe8ce710b17ff41711e13010ead2abfc3e5", size = 32764, upload-time = "2024-02-18T19:09:04.156Z" },
]
+[[package]]
+name = "certifi"
+version = "2025.8.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" },
+]
+
[[package]]
name = "cffi"
version = "1.17.1"
@@ -171,6 +192,37 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009, upload-time = "2024-09-04T20:44:45.309Z" },
]
+[[package]]
+name = "charset-normalizer"
+version = "3.4.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371, upload-time = "2025-08-09T07:57:28.46Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326, upload-time = "2025-08-09T07:56:24.721Z" },
+ { url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008, upload-time = "2025-08-09T07:56:26.004Z" },
+ { url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196, upload-time = "2025-08-09T07:56:27.25Z" },
+ { url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819, upload-time = "2025-08-09T07:56:28.515Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350, upload-time = "2025-08-09T07:56:29.716Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644, upload-time = "2025-08-09T07:56:30.984Z" },
+ { url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468, upload-time = "2025-08-09T07:56:32.252Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187, upload-time = "2025-08-09T07:56:33.481Z" },
+ { url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699, upload-time = "2025-08-09T07:56:34.739Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580, upload-time = "2025-08-09T07:56:35.981Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366, upload-time = "2025-08-09T07:56:37.339Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342, upload-time = "2025-08-09T07:56:38.687Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995, upload-time = "2025-08-09T07:56:40.048Z" },
+ { url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640, upload-time = "2025-08-09T07:56:41.311Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636, upload-time = "2025-08-09T07:56:43.195Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939, upload-time = "2025-08-09T07:56:44.819Z" },
+ { url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580, upload-time = "2025-08-09T07:56:46.684Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870, upload-time = "2025-08-09T07:56:47.941Z" },
+ { url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797, upload-time = "2025-08-09T07:56:49.756Z" },
+ { url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224, upload-time = "2025-08-09T07:56:51.369Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086, upload-time = "2025-08-09T07:56:52.722Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400, upload-time = "2025-08-09T07:56:55.172Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" },
+]
+
[[package]]
name = "cryptography"
version = "45.0.7"
@@ -403,14 +455,17 @@ dependencies = [
{ name = "aiohttp" },
{ name = "aiohttp-security" },
{ name = "aiohttp-session" },
+ { name = "authlib" },
{ name = "bcrypt" },
{ name = "cryptography" },
{ name = "peewee" },
{ name = "pillow" },
{ name = "pydantic", extra = ["email"] },
+ { name = "python-dotenv" },
{ name = "python-engineio" },
{ name = "python-socketio" },
{ name = "redmail" },
+ { name = "requests" },
{ name = "rtoml" },
{ name = "typing-extensions" },
{ name = "watchdog" },
@@ -427,14 +482,17 @@ requires-dist = [
{ name = "aiohttp", specifier = "==3.12.15" },
{ name = "aiohttp-security", specifier = "==0.5.0" },
{ name = "aiohttp-session", specifier = "==2.12.1" },
+ { name = "authlib", specifier = "==1.2.1" },
{ name = "bcrypt", specifier = "==4.3.0" },
{ name = "cryptography", specifier = "==45.0.7" },
{ name = "peewee", specifier = "==3.18.2" },
{ name = "pillow", specifier = "==11.3.0" },
{ name = "pydantic", extras = ["email"], specifier = "==1.10.18" },
+ { name = "python-dotenv", specifier = ">=1.0.0" },
{ name = "python-engineio", specifier = "==4.12.2" },
{ name = "python-socketio", specifier = "==5.13.0" },
{ name = "redmail", specifier = "==0.6.0" },
+ { name = "requests", specifier = ">=2.32.5" },
{ name = "rtoml", specifier = "==0.12.0" },
{ name = "typing-extensions", specifier = "==4.15.0" },
{ name = "watchdog", specifier = "==6.0.0" },
@@ -525,6 +583,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fe/0e/bf1f77c475164d3b5e21b112ce43dffed1ed42b9a2667df03522422c5711/pydantic_to_typescript-1.0.10-py3-none-any.whl", hash = "sha256:b2b3954fd4aa55f367aa0513ee3c21cd327221936c0cfbeb8d46b51542eceea7", size = 8051, upload-time = "2022-08-30T01:25:02.899Z" },
]
+[[package]]
+name = "python-dotenv"
+version = "1.1.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" },
+]
+
[[package]]
name = "python-engineio"
version = "4.12.2"
@@ -562,6 +629,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/83/67/3e0005b255a9d02448c5529af450b6807403e9af7b82636123273906ea37/redmail-0.6.0-py3-none-any.whl", hash = "sha256:8e64a680ffc8aaf8054312bf8b216da8fed20669181b77b1f1ccbdf4ee064427", size = 46948, upload-time = "2023-02-25T10:20:50.438Z" },
]
+[[package]]
+name = "requests"
+version = "2.32.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "charset-normalizer" },
+ { name = "idna" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
+]
+
[[package]]
name = "rtoml"
version = "0.12.0"
@@ -630,6 +712,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
+[[package]]
+name = "urllib3"
+version = "2.5.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
+]
+
[[package]]
name = "watchdog"
version = "6.0.0"