feature: add oidc login flow
This commit is contained in:
15
README.md
15
README.md
@ -20,10 +20,13 @@ Then open `http://localhost:8000/` and set the API token in the UI.
|
||||
|
||||
## Configuration
|
||||
Set in `/etc/skd/env` (see `env.example`):
|
||||
- `SKD_AUTH_SECRET`: HMAC secret for bearer tokens (set a strong value).
|
||||
- `SKD_AUTH_MODE`: `pam` (default) or `oidc`.
|
||||
- `SKD_AUTH_SECRET`: HMAC secret for bearer tokens/cookies (set a strong value).
|
||||
- `SKD_TOKEN_TTL_SECONDS`: token lifetime (default 900s).
|
||||
- `SKD_AUTH_ALLOWED_USERS`: optional comma list of accounts allowed to log in.
|
||||
- `SKD_AUTH_ALLOWED_GROUPS`: groups whose members may log in (default `sudo`).
|
||||
- `SKD_AUTH_ALLOWED_USERS`: optional comma list of accounts allowed to log in (used for PAM and as an allowlist for OIDC claims).
|
||||
- `SKD_AUTH_ALLOWED_GROUPS`: groups whose members may log in (PAM only, default `sudo`).
|
||||
- `SKD_OIDC_*`: `ISSUER`, `CLIENT_ID`, `CLIENT_SECRET`, `REDIRECT_URI`, `SCOPES` to point at your OIDC provider; set `SKD_SESSION_COOKIE_SECURE=true` for HTTPS.
|
||||
- OIDC dynamic registration helper: `scripts/register_oidc_client.sh` (requires `OIDC_INITIAL_ACCESS_TOKEN` and `SKD_OIDC_ISSUER`; uses `SKD_OIDC_REDIRECT_URI` for the redirect). Run once during setup if your provider issues initial access tokens for client creation.
|
||||
- `SKD_ALLOWED_USERS`: optional comma list to limit manageable accounts (must exist on the system).
|
||||
- `SKD_DEFAULT_COUNTDOWN`, `SKD_DEFAULT_SOUND`, `SKD_NOTIFY_TIMEOUT`: behavior defaults.
|
||||
- `SKD_DRY_RUN=true` to test without real account changes or shutdown.
|
||||
@ -34,7 +37,8 @@ Notes:
|
||||
## Running
|
||||
- Service: managed by systemd; `./scripts/install.sh` writes the unit dynamically to `/etc/systemd/system/skd.service` with the current repo path and restarts it.
|
||||
- Manual run: `./scripts/run.sh` (uses `.venv`, defaults to `0.0.0.0:8000`).
|
||||
- Login: `curl -X POST -H "Content-Type: application/json" -d '{"username":"root","password":"..."}' http://localhost:8000/login`
|
||||
- Login (PAM): `curl -X POST -H "Content-Type: application/json" -d '{"username":"root","password":"..."}' http://localhost:8000/login`
|
||||
- Login (OIDC): open `http://localhost:8000/login/oidc/start` → provider → redirected back with session cookie set.
|
||||
- Health: `curl -H "Authorization: Bearer <token>" http://localhost:8000/health`
|
||||
|
||||
## API (Bearer token via `/login`)
|
||||
@ -42,6 +46,7 @@ Notes:
|
||||
- `POST /users/{name}/disable` with JSON `{countdown?, sound?, message?}`
|
||||
- `POST /users/{name}/enable`
|
||||
- `GET /health`
|
||||
- `GET /me` (returns current user + auth mode when a session/bearer token is present)
|
||||
|
||||
Example:
|
||||
```bash
|
||||
@ -53,7 +58,7 @@ curl -X POST -H "Authorization: Bearer $token" \
|
||||
```
|
||||
|
||||
## Web UI
|
||||
Served at `/`. Login mit Root-Account, danach werden verfügbare System-User angezeigt; Aktionen senden Bearer Token automatisch.
|
||||
Served at `/`. Nutze den Button „Login via OIDC“ (setzt Session-Cookie) oder das PAM-Formular, falls OIDC deaktiviert; danach werden verfügbare System-User angezeigt und Aktionen senden Token/Cookies automatisch.
|
||||
|
||||
## Updates
|
||||
- Remote update via SSH: `ssh user@kid-laptop 'cd /opt/sk && ./scripts/update.sh'` (fetch/reset to `origin/main`, reinstalls deps, restarts service).
|
||||
|
||||
100
backend/app.py
100
backend/app.py
@ -1,14 +1,21 @@
|
||||
import logging
|
||||
from typing import List
|
||||
|
||||
from fastapi import Body, Depends, FastAPI, HTTPException, Request, status
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi import Body, Depends, FastAPI, HTTPException, Request, Response, status
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from backend import actions
|
||||
from backend.actions import ActionError
|
||||
from backend.auth import authenticate_admin_user, get_current_admin, issue_token, list_manageable_users
|
||||
from backend.auth import (
|
||||
authenticate_admin_user,
|
||||
get_current_admin,
|
||||
is_authorized_admin,
|
||||
issue_token,
|
||||
list_manageable_users,
|
||||
)
|
||||
from backend.models import ActionRequest, ActionResponse, LoginRequest, LoginResponse, UserStatus
|
||||
from backend.oidc import OIDCClient, OIDCError
|
||||
from backend.settings import Settings, get_settings
|
||||
|
||||
logging.basicConfig(
|
||||
@ -21,6 +28,20 @@ app = FastAPI(title="Safe Kiddo Daemon", version="1.0.0")
|
||||
templates = Jinja2Templates(directory="backend/templates")
|
||||
|
||||
|
||||
def get_oidc_client(settings: Settings = Depends(get_settings)) -> OIDCClient:
|
||||
if settings.auth_mode != "oidc":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="OIDC auth not enabled",
|
||||
)
|
||||
try:
|
||||
return OIDCClient(settings)
|
||||
except OIDCError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
def validate_user(username: str, settings: Settings = Depends(get_settings)) -> str:
|
||||
allowed = set(list_manageable_users(settings))
|
||||
if username not in allowed:
|
||||
@ -33,13 +54,84 @@ def health(settings: Settings = Depends(get_settings)) -> dict:
|
||||
return {"status": "ok", "dry_run": settings.dry_run}
|
||||
|
||||
|
||||
@app.get("/me")
|
||||
def whoami(
|
||||
current_user: str = Depends(get_current_admin),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> dict:
|
||||
return {"user": current_user, "auth_mode": settings.auth_mode}
|
||||
|
||||
|
||||
@app.post("/login", response_model=LoginResponse)
|
||||
def login(payload: LoginRequest, settings: Settings = Depends(get_settings)) -> LoginResponse:
|
||||
def login(
|
||||
payload: LoginRequest,
|
||||
response: Response,
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> LoginResponse:
|
||||
authenticate_admin_user(payload.username, payload.password, settings)
|
||||
token = issue_token(payload.username, settings)
|
||||
response.set_cookie(
|
||||
settings.session_cookie_name,
|
||||
token,
|
||||
max_age=settings.token_ttl_seconds,
|
||||
httponly=True,
|
||||
secure=settings.session_cookie_secure,
|
||||
samesite="lax",
|
||||
)
|
||||
return LoginResponse(token=token, expires_in=settings.token_ttl_seconds)
|
||||
|
||||
|
||||
@app.get("/login/oidc/start")
|
||||
def oidc_start(
|
||||
settings: Settings = Depends(get_settings),
|
||||
oidc: OIDCClient = Depends(get_oidc_client),
|
||||
):
|
||||
state = oidc.build_state_token()
|
||||
redirect = RedirectResponse(url=oidc.authorization_url(state))
|
||||
redirect.set_cookie(
|
||||
settings.oidc_state_cookie_name,
|
||||
state,
|
||||
max_age=300,
|
||||
httponly=True,
|
||||
secure=settings.session_cookie_secure,
|
||||
samesite="lax",
|
||||
)
|
||||
return redirect
|
||||
|
||||
|
||||
@app.get("/login/oidc/callback")
|
||||
def oidc_callback(
|
||||
request: Request,
|
||||
code: str,
|
||||
state: str,
|
||||
settings: Settings = Depends(get_settings),
|
||||
oidc: OIDCClient = Depends(get_oidc_client),
|
||||
):
|
||||
stored_state = request.cookies.get(settings.oidc_state_cookie_name, "")
|
||||
if not oidc.is_state_valid(state, stored_state):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid OIDC state")
|
||||
|
||||
claims = oidc.exchange_code_for_claims(code)
|
||||
username = oidc.extract_username(claims)
|
||||
if not username:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Missing username claim")
|
||||
if not is_authorized_admin(username, settings):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User not authorized to log in")
|
||||
|
||||
token = issue_token(username, settings)
|
||||
redirect = RedirectResponse(url="/")
|
||||
redirect.set_cookie(
|
||||
settings.session_cookie_name,
|
||||
token,
|
||||
max_age=settings.token_ttl_seconds,
|
||||
httponly=True,
|
||||
secure=settings.session_cookie_secure,
|
||||
samesite="lax",
|
||||
)
|
||||
redirect.delete_cookie(settings.oidc_state_cookie_name)
|
||||
return redirect
|
||||
|
||||
|
||||
@app.get("/users", response_model=List[UserStatus], dependencies=[Depends(get_current_admin)])
|
||||
def users(settings: Settings = Depends(get_settings)) -> List[UserStatus]:
|
||||
logged_in = set(actions.list_logged_in_users())
|
||||
|
||||
@ -24,6 +24,11 @@ def _is_member_of(username: str, groups: Set[str]) -> bool:
|
||||
|
||||
|
||||
def is_authorized_admin(username: str, settings: Settings) -> bool:
|
||||
if settings.auth_mode == "oidc":
|
||||
allowed_users = set(settings.auth_allowed_users)
|
||||
if allowed_users and username not in allowed_users:
|
||||
return False
|
||||
return True
|
||||
# UID 0 always allowed
|
||||
try:
|
||||
entry = pwd.getpwnam(username)
|
||||
@ -41,6 +46,11 @@ def is_authorized_admin(username: str, settings: Settings) -> bool:
|
||||
|
||||
|
||||
def authenticate_admin_user(username: str, password: str, settings: Settings) -> None:
|
||||
if settings.auth_mode != "pam":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Password login disabled; OIDC is configured",
|
||||
)
|
||||
if not is_authorized_admin(username, settings):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
@ -82,9 +92,13 @@ def get_current_admin(
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> str:
|
||||
auth_header = request.headers.get("authorization")
|
||||
if not auth_header or not auth_header.lower().startswith("bearer "):
|
||||
token = None
|
||||
if auth_header and auth_header.lower().startswith("bearer "):
|
||||
token = auth_header.split(" ", 1)[1].strip()
|
||||
if not token:
|
||||
token = request.cookies.get(settings.session_cookie_name)
|
||||
if not token:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token")
|
||||
token = auth_header.split(" ", 1)[1].strip()
|
||||
return decode_token(token, settings)
|
||||
|
||||
|
||||
|
||||
140
backend/oidc.py
Normal file
140
backend/oidc.py
Normal file
@ -0,0 +1,140 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
import urllib.parse
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
from fastapi import HTTPException, status
|
||||
from jwt import PyJWKClient
|
||||
|
||||
from backend.settings import Settings
|
||||
|
||||
|
||||
@dataclass
|
||||
class OIDCConfig:
|
||||
issuer: str
|
||||
authorization_endpoint: str
|
||||
token_endpoint: str
|
||||
jwks_uri: str
|
||||
userinfo_endpoint: Optional[str] = None
|
||||
|
||||
|
||||
class OIDCError(Exception):
|
||||
"""Raised when the OIDC provider cannot be used."""
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _load_provider_config(issuer: str) -> OIDCConfig:
|
||||
discovery_url = urllib.parse.urljoin(issuer.rstrip("/") + "/", ".well-known/openid-configuration")
|
||||
try:
|
||||
with httpx.Client(timeout=5.0) as client:
|
||||
resp = client.get(discovery_url)
|
||||
resp.raise_for_status()
|
||||
except httpx.HTTPError as exc: # pragma: no cover - network failure branch
|
||||
raise OIDCError(f"Failed to load discovery document: {exc}") from exc
|
||||
|
||||
data = resp.json()
|
||||
required = ("issuer", "authorization_endpoint", "token_endpoint", "jwks_uri")
|
||||
if not all(key in data for key in required):
|
||||
raise OIDCError("Discovery document missing required fields")
|
||||
|
||||
return OIDCConfig(
|
||||
issuer=data["issuer"],
|
||||
authorization_endpoint=data["authorization_endpoint"],
|
||||
token_endpoint=data["token_endpoint"],
|
||||
jwks_uri=data["jwks_uri"],
|
||||
userinfo_endpoint=data.get("userinfo_endpoint"),
|
||||
)
|
||||
|
||||
|
||||
class OIDCClient:
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self.settings = settings
|
||||
if not settings.oidc_issuer:
|
||||
raise OIDCError("SKD_OIDC_ISSUER not configured")
|
||||
if not settings.oidc_client_id or not settings.oidc_client_secret:
|
||||
raise OIDCError("SKD_OIDC_CLIENT_ID/SECRET must be set")
|
||||
self.config = _load_provider_config(settings.oidc_issuer)
|
||||
self.jwk_client = PyJWKClient(self.config.jwks_uri)
|
||||
|
||||
def build_state_token(self) -> str:
|
||||
raw = secrets.token_urlsafe(24)
|
||||
sig = hmac.new(self.settings.auth_secret.encode(), raw.encode(), hashlib.sha256).hexdigest()
|
||||
return f"{raw}.{sig}"
|
||||
|
||||
def is_state_valid(self, provided: str, stored: str) -> bool:
|
||||
if not provided or not stored or provided != stored:
|
||||
return False
|
||||
try:
|
||||
raw, sig = provided.split(".", 1)
|
||||
except ValueError:
|
||||
return False
|
||||
expected = hmac.new(self.settings.auth_secret.encode(), raw.encode(), hashlib.sha256).hexdigest()
|
||||
return hmac.compare_digest(sig, expected)
|
||||
|
||||
def authorization_url(self, state: str) -> str:
|
||||
params = {
|
||||
"client_id": self.settings.oidc_client_id,
|
||||
"redirect_uri": self.settings.oidc_redirect_uri,
|
||||
"response_type": "code",
|
||||
"scope": self.settings.oidc_scopes,
|
||||
"state": state,
|
||||
}
|
||||
return f"{self.config.authorization_endpoint}?{urllib.parse.urlencode(params)}"
|
||||
|
||||
def exchange_code_for_claims(self, code: str) -> Dict[str, Any]:
|
||||
payload = {
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": self.settings.oidc_redirect_uri,
|
||||
"client_id": self.settings.oidc_client_id,
|
||||
"client_secret": self.settings.oidc_client_secret,
|
||||
}
|
||||
headers = {"Content-Type": "application/x-www-form-urlencoded"}
|
||||
try:
|
||||
with httpx.Client(timeout=10.0) as client:
|
||||
resp = client.post(self.config.token_endpoint, data=payload, headers=headers)
|
||||
except httpx.HTTPError as exc: # pragma: no cover - network failure branch
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=f"OIDC token request failed: {exc}",
|
||||
) from exc
|
||||
|
||||
if resp.status_code != status.HTTP_200_OK:
|
||||
detail = resp.text or "token request failed"
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=f"OIDC token exchange failed: {detail}",
|
||||
)
|
||||
|
||||
token_response = resp.json()
|
||||
id_token = token_response.get("id_token")
|
||||
if not id_token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="OIDC response missing id_token",
|
||||
)
|
||||
|
||||
signing_key = self.jwk_client.get_signing_key_from_jwt(id_token).key
|
||||
try:
|
||||
claims = jwt.decode(
|
||||
id_token,
|
||||
signing_key,
|
||||
algorithms=["RS256"],
|
||||
audience=self.settings.oidc_client_id,
|
||||
issuer=self.config.issuer,
|
||||
)
|
||||
except jwt.PyJWTError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=f"Invalid ID token: {exc}",
|
||||
) from exc
|
||||
return claims
|
||||
|
||||
@staticmethod
|
||||
def extract_username(claims: Dict[str, Any]) -> Optional[str]:
|
||||
return claims.get("preferred_username") or claims.get("email") or claims.get("sub")
|
||||
@ -5,3 +5,5 @@ jinja2==3.1.4
|
||||
PyJWT==2.9.0
|
||||
python-pam==2.0.2
|
||||
six==1.16.0
|
||||
httpx==0.27.2
|
||||
cryptography==43.0.1
|
||||
|
||||
@ -7,6 +7,9 @@ class Settings:
|
||||
"""Application settings loaded from environment."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.auth_mode: str = os.getenv("SKD_AUTH_MODE", "pam").lower()
|
||||
if self.auth_mode not in ("pam", "oidc"):
|
||||
self.auth_mode = "pam"
|
||||
self.allowed_users: List[str] = self._parse_list(os.getenv("SKD_ALLOWED_USERS", ""))
|
||||
self.auth_secret: str = os.getenv("SKD_AUTH_SECRET", "change-me-secret")
|
||||
self.token_ttl_seconds: int = int(os.getenv("SKD_TOKEN_TTL_SECONDS", "900"))
|
||||
@ -15,6 +18,20 @@ class Settings:
|
||||
os.getenv("SKD_AUTH_ALLOWED_GROUPS", "sudo")
|
||||
)
|
||||
self.auth_pam_service: str = os.getenv("SKD_AUTH_PAM_SERVICE", "login")
|
||||
self.oidc_issuer: str = os.getenv("SKD_OIDC_ISSUER", "")
|
||||
self.oidc_client_id: str = os.getenv("SKD_OIDC_CLIENT_ID", "")
|
||||
self.oidc_client_secret: str = os.getenv("SKD_OIDC_CLIENT_SECRET", "")
|
||||
self.oidc_redirect_uri: str = os.getenv(
|
||||
"SKD_OIDC_REDIRECT_URI", "http://localhost:8000/login/oidc/callback"
|
||||
)
|
||||
self.oidc_scopes: str = os.getenv("SKD_OIDC_SCOPES", "openid profile email")
|
||||
self.session_cookie_name: str = os.getenv("SKD_SESSION_COOKIE_NAME", "skd_session")
|
||||
self.session_cookie_secure: bool = (
|
||||
os.getenv("SKD_SESSION_COOKIE_SECURE", "false").lower() == "true"
|
||||
)
|
||||
self.oidc_state_cookie_name: str = os.getenv(
|
||||
"SKD_OIDC_STATE_COOKIE_NAME", "skd_oidc_state"
|
||||
)
|
||||
self.default_countdown: int = int(os.getenv("SKD_DEFAULT_COUNTDOWN", "60"))
|
||||
self.default_sound: bool = os.getenv("SKD_DEFAULT_SOUND", "false").lower() == "true"
|
||||
self.notify_timeout: int = int(os.getenv("SKD_NOTIFY_TIMEOUT", "5"))
|
||||
|
||||
@ -19,6 +19,10 @@
|
||||
|
||||
<section>
|
||||
<h3>Login (nur Root-User)</h3>
|
||||
<p>Bevorzugt OIDC nutzen, falls konfiguriert. Die Anmeldung öffnet den Identity Provider und setzt eine Session-Cookie.</p>
|
||||
<button id="oidcLogin" type="button">Login via OIDC</button>
|
||||
<hr />
|
||||
<p>Lokale Anmeldung (PAM) nur falls OIDC nicht verfügbar:</p>
|
||||
<form id="loginForm">
|
||||
<div class="grid">
|
||||
<div>
|
||||
@ -91,7 +95,7 @@
|
||||
currentToken = token;
|
||||
if (token) {
|
||||
sessionStorage.setItem(tokenKey, token);
|
||||
loginStatus.textContent = 'Angemeldet';
|
||||
loginStatus.textContent = 'Angemeldet (Token gespeichert)';
|
||||
} else {
|
||||
sessionStorage.removeItem(tokenKey);
|
||||
loginStatus.textContent = 'Nicht angemeldet';
|
||||
@ -107,11 +111,31 @@
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const headers = { ...authHeaders(), ...(options.headers || {}) };
|
||||
const res = await fetch(path, { ...options, headers });
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
const res = await fetch(path, { ...options, headers, credentials: 'same-origin' });
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
const error = new Error(text || `${res.status} ${res.statusText}`);
|
||||
error.status = res.status;
|
||||
throw error;
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function checkSession() {
|
||||
try {
|
||||
const data = await api('/me');
|
||||
loginStatus.textContent = `Angemeldet als ${data.user} (${data.auth_mode})`;
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err.status === 401) {
|
||||
loginStatus.textContent = 'Nicht angemeldet';
|
||||
} else {
|
||||
loginStatus.textContent = `Session-Check fehlgeschlagen: ${err.message}`;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshUsers() {
|
||||
statusDiv.textContent = 'Lade...';
|
||||
try {
|
||||
@ -149,6 +173,10 @@
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('oidcLogin').addEventListener('click', () => {
|
||||
window.location.href = '/login/oidc/start';
|
||||
});
|
||||
|
||||
document.getElementById('refreshBtn').addEventListener('click', refreshUsers);
|
||||
|
||||
document.getElementById('actionForm').addEventListener('submit', async (e) => {
|
||||
@ -176,6 +204,8 @@
|
||||
resultDiv.textContent = `Fehler: ${err.message}`;
|
||||
}
|
||||
});
|
||||
|
||||
checkSession();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
10
env.example
10
env.example
@ -3,9 +3,19 @@
|
||||
SKD_ALLOWED_USERS=child1,child2
|
||||
SKD_AUTH_SECRET=change-me-secret
|
||||
SKD_TOKEN_TTL_SECONDS=900
|
||||
# Auth mode: pam (default) or oidc
|
||||
SKD_AUTH_MODE=pam
|
||||
SKD_AUTH_ALLOWED_USERS=
|
||||
SKD_AUTH_ALLOWED_GROUPS=sudo
|
||||
SKD_AUTH_PAM_SERVICE=login
|
||||
SKD_OIDC_ISSUER=
|
||||
SKD_OIDC_CLIENT_ID=
|
||||
SKD_OIDC_CLIENT_SECRET=
|
||||
SKD_OIDC_REDIRECT_URI=http://localhost:8000/login/oidc/callback
|
||||
SKD_OIDC_SCOPES=openid profile email
|
||||
SKD_SESSION_COOKIE_NAME=skd_session
|
||||
SKD_SESSION_COOKIE_SECURE=false
|
||||
SKD_OIDC_STATE_COOKIE_NAME=skd_oidc_state
|
||||
SKD_DEFAULT_COUNTDOWN=60
|
||||
SKD_DEFAULT_SOUND=false
|
||||
SKD_NOTIFY_TIMEOUT=5
|
||||
|
||||
74
scripts/register_oidc_client.sh
Executable file
74
scripts/register_oidc_client.sh
Executable file
@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
|
||||
log() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"
|
||||
}
|
||||
|
||||
require_cmd() {
|
||||
if ! command -v "$1" >/dev/null 2>&1; then
|
||||
echo "Required command not found: $1" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
require_cmd curl
|
||||
require_cmd python3
|
||||
|
||||
ISSUER="${SKD_OIDC_ISSUER:-}"
|
||||
REG_ENDPOINT_DEFAULT=""
|
||||
if [[ -n "${ISSUER}" ]]; then
|
||||
REG_ENDPOINT_DEFAULT="${ISSUER%/}/connect/register"
|
||||
fi
|
||||
|
||||
REG_ENDPOINT="${OIDC_REGISTRATION_ENDPOINT:-$REG_ENDPOINT_DEFAULT}"
|
||||
INITIAL_TOKEN="${OIDC_INITIAL_ACCESS_TOKEN:-}"
|
||||
CLIENT_NAME="${OIDC_CLIENT_NAME:-Safe Kiddo Daemon}"
|
||||
REDIRECT_URI="${SKD_OIDC_REDIRECT_URI:-http://localhost:8000/login/oidc/callback}"
|
||||
|
||||
if [[ -z "${REG_ENDPOINT}" || -z "${INITIAL_TOKEN}" ]]; then
|
||||
cat >&2 <<'EOF'
|
||||
Missing configuration. Set:
|
||||
SKD_OIDC_ISSUER (or OIDC_REGISTRATION_ENDPOINT)
|
||||
OIDC_INITIAL_ACCESS_TOKEN
|
||||
Optional:
|
||||
OIDC_CLIENT_NAME (default: Safe Kiddo Daemon)
|
||||
SKD_OIDC_REDIRECT_URI (default: http://localhost:8000/login/oidc/callback)
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "Registering client at ${REG_ENDPOINT} with redirect ${REDIRECT_URI}..."
|
||||
|
||||
TMP_RESP="$(mktemp)"
|
||||
trap 'rm -f "${TMP_RESP}"' EXIT
|
||||
|
||||
HTTP_CODE=$(curl -sS -o "${TMP_RESP}" -w '%{http_code}' \
|
||||
-X POST "${REG_ENDPOINT}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer ${INITIAL_TOKEN}" \
|
||||
-d "{\"client_name\":\"${CLIENT_NAME}\",\"redirect_uris\":[\"${REDIRECT_URI}\"]}")
|
||||
|
||||
if [[ "${HTTP_CODE}" != "200" && "${HTTP_CODE}" != "201" ]]; then
|
||||
echo "Client registration failed (HTTP ${HTTP_CODE}):" >&2
|
||||
cat "${TMP_RESP}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
python3 - "$TMP_RESP" <<'PYCODE'
|
||||
import json, sys
|
||||
path = sys.argv[1]
|
||||
data = json.load(open(path, "r"))
|
||||
client_id = data.get("client_id")
|
||||
client_secret = data.get("client_secret")
|
||||
print("Client registered.")
|
||||
if client_id:
|
||||
print(f"Client ID: {client_id}")
|
||||
if client_secret:
|
||||
print(f"Client Secret: {client_secret}")
|
||||
if not (client_id and client_secret):
|
||||
print("Warning: Response missing client_id or client_secret", file=sys.stderr)
|
||||
PYCODE
|
||||
Reference in New Issue
Block a user