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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,47 @@ Changelog ist die Upgrade-Anleitung für die Tools.

## [Unreleased]

### basicbar-lti (→ wird `lti/v0.1.4`)

**Sicherheit: Reflected XSS im LTI-Login behoben.** Die 400er-Antworten von
`lti_login` spiegelten `iss`/`client_id` aus GET-Parametern in eine
HTML-Antwort (Django-Default-Content-Type) — eine präparierte URL konnte so
Skript auf der Tool-Origin ausführen. Alle Fehlerantworten des Endpunkts sind
jetzt `text/plain`. Migration: Version heben, sonst nichts.

### basicbar-auth (→ wird `auth/v0.1.1`)

**Sicherheit: Back-Channel-Logout-Tokens werden auf Frische geprüft.** Bisher
prüfte der Endpunkt nur Signatur, Nonce-Verbot und iss/aud/events/sub — ein
einmal abgefangenes Token ließ sich unbegrenzt wiederholen (erzwungener
Logout als DoS). Jetzt ist `iat` Pflicht und darf höchstens
`OIDC_BACKCHANNEL_MAX_AGE` Sekunden alt sein (neues Setting, Default 300);
`exp` wird respektiert. Migration: Version heben; bei stark abweichenden
Uhren zwischen IdP und Tool ggf. `OIDC_BACKCHANNEL_MAX_AGE` erhöhen.

### basicbar-integrations (→ wird `integrations/v0.2.2`)

- `ai.chat_json` und `translation_service.translate` fangen jetzt alle
Transportfehler (`ConnectionResetError`, `IncompleteRead`, …) als
`AIError`/`TranslationError`, statt sie als 500 durchzulassen.
- HTTP-Fehler tragen den Provider-Body in der Meldung (z. B.
„HTTP 400: en is not supported“) statt nur „Bad Request“;
LibreTranslate-4xx heißen nicht mehr fälschlich „unavailable“.
- Nicht-Objekt-JSON vom Übersetzungsdienst ist ein `TranslationError`.
- Migration: Version heben; wer `str(exc)` an Nutzer durchreicht, zeigt jetzt
aussagekräftigere Texte.

### Template

- `REST_FRAMEWORK.DEFAULT_AUTHENTICATION_CLASSES` nur noch
`SessionAuthentication` — DRFs Default aktiviert `BasicAuthentication`, die
mit dem Break-glass-Superuser (ModelBackend) jeden Endpunkt für
Passwort-Raten ohne Rate-Limit und am CSRF vorbei öffnet. **Empfehlung für
Bestandstools** (abstimmbar, ausleihbar; erkennbar hat es bereits): dieselbe
Zeile in `config/settings.py` übernehmen.
- CLAUDE.md: Der ui-Release entsteht als Draft und muss manuell veröffentlicht
werden, sonst ist der Tarball nicht abrufbar.

### @basicbar/ui (→ wird `ui/v0.6.0`)

**Geteilte Einstellungs-Bausteine** (ausleihbar#35): `LanguageOptions`,
Expand Down Expand Up @@ -152,6 +193,14 @@ Migration: keine — rein additiv. Neue UI-Strings `"Show all fields in one
language"` und `"Show all fields in {{language}}"` (Tools ergänzen ihre
Übersetzungen).

### basicbar-lti (`lti/v0.1.3`, 2026-08-04 — Eintrag nachgetragen)

- `lti_login` antwortet bei fehlenden Parametern (`iss`, `login_hint`,
`target_link_uri`) und nicht auflösbarer Plattform-Registrierung
(Issuer/Client-ID-Mismatch, auch Trailing-Slash) mit erklärendem `400`
statt opakem `500`; pylti1p3-Exceptions werden abgefangen. Migration:
Version heben, sonst nichts.

### basicbar-integrations (→ wird `integrations/v0.2.0`) und @basicbar/ui (→ wird `ui/v0.3.0`)

**Veraltete Übersetzungen markieren** (modulierbar#31, generisch für alle
Expand Down
5 changes: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,10 @@ pipx run copier copy . /tmp/probe --trust --defaults --vcs-ref HEAD --data proje
**Tag `<paket>/vX.Y.Z` erst nach dem Merge** auf den main-Commit setzen
(sonst zeigt er auf verwaiste Commits). Beim Tag `ui/v*` packt die CI
`@basicbar/ui` per `npm pack` und hängt den Tarball als GitHub-Release-Asset
an; die Django-Pakete werden von den Tools direkt als GitHub-Archiv-Tarball
an — **als Draft**: Assets eines Drafts sind nicht öffentlich, die URL in
den Tool-`package.json`s funktioniert erst nach dem manuellen „Publish
release“ (`gh release edit ui/vX.Y.Z --draft=false`). Die Django-Pakete
werden von den Tools direkt als GitHub-Archiv-Tarball
vom Tag installiert (`/archive/refs/tags/<tag>.tar.gz#subdirectory=…` — Repo
ist deshalb öffentlich). Details/Gründe des Hosts:
[ADR-0004](docs/ADR/0004-umzug-nach-github.md).
Expand Down
2 changes: 1 addition & 1 deletion packages/django/basicbar-auth/basicbar_auth/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
# Copyright 2026 Universität Osnabrück (virtUOS)

__version__ = "0.1.0"
__version__ = "0.1.1"
3 changes: 3 additions & 0 deletions packages/django/basicbar-auth/basicbar_auth/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@
"OIDC_ADMIN_GROUP": "",
# Issuer used to validate back-channel logout tokens (empty = skip check).
"OIDC_OP_ISSUER": "",
# Back-channel logout tokens older than this (seconds, measured from
# ``iat``) are rejected as replays; also absorbs clock skew.
"OIDC_BACKCHANNEL_MAX_AGE": 300,
# Opt-in: when the IdP re-issued its subjects (re-imported dev realm,
# realm/IdP migration), fall back to a username match instead of crashing
# into the unique-username constraint. Only enable when the IdP never
Expand Down
23 changes: 23 additions & 0 deletions packages/django/basicbar-auth/basicbar_auth/oidc.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
deployment opts in; see the README's operator notes.
"""
import logging
import time
from urllib.parse import urlencode

from django.conf import settings
Expand All @@ -31,6 +32,14 @@
# Back-Channel Logout 1.0, §2.4).
BACKCHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout"

# Tolerance when comparing a token's ``exp`` against our clock.
_CLOCK_SKEW_SECONDS = 30


def _is_timestamp(value) -> bool:
"""A JWT NumericDate: int or float, but not bool (which is an int)."""
return isinstance(value, (int, float)) and not isinstance(value, bool)


def claims_in_admin_group(claims) -> bool:
"""Whether the given OIDC ``claims`` place the user in the configured admin
Expand Down Expand Up @@ -242,6 +251,20 @@ def backchannel_logout(request):
# We key local sessions on the subject; a sid-only token can't be acted on.
return HttpResponseBadRequest("missing sub")

# Freshness (§2.6): the token must carry ``iat`` and be recent — mozilla's
# verify_token checks only signature and nonce, so without this a captured
# token could be replayed indefinitely to force the user out again.
now = time.time()
max_age = conf.get("OIDC_BACKCHANNEL_MAX_AGE")
iat = payload.get("iat")
if not _is_timestamp(iat):
return HttpResponseBadRequest("missing iat")
if abs(now - iat) > max_age:
return HttpResponseBadRequest("logout_token too old")
exp = payload.get("exp")
if _is_timestamp(exp) and exp < now - _CLOCK_SKEW_SECONDS:
return HttpResponseBadRequest("logout_token expired")

deleted = _delete_sessions_for_subject(subject)
logger.info("Back-channel logout for sub=%s dropped %d session(s)", subject, deleted)
# Spec: 200 with no caching on success.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
# Copyright 2026 Universität Osnabrück (virtUOS)

"""OIDC Back-Channel Logout (ported from ausleihbar)."""
import time
from unittest.mock import patch

from django.contrib.auth import get_user_model
Expand Down Expand Up @@ -32,9 +33,41 @@ def _valid_payload(self, sub="sub-123"):
"iss": "https://idp.test/realms/x",
"aud": "test-client",
"sub": sub,
"iat": int(time.time()),
"events": {BACKCHANNEL_LOGOUT_EVENT: {}},
}

@patch("basicbar_auth.oidc.OIDCBackend.verify_token")
def test_token_without_iat_is_rejected(self, mock_verify):
payload = self._valid_payload()
del payload["iat"]
mock_verify.return_value = payload
self.assertEqual(self.client.post(self.url, {"logout_token": "tok"}).status_code, 400)

@patch("basicbar_auth.oidc.OIDCBackend.verify_token")
def test_stale_token_is_rejected_as_replay(self, mock_verify):
key = self._login(self.user)
payload = self._valid_payload()
payload["iat"] = int(time.time()) - 3600
mock_verify.return_value = payload
self.assertEqual(self.client.post(self.url, {"logout_token": "tok"}).status_code, 400)
self.assertTrue(Session.objects.filter(session_key=key).exists())

@patch("basicbar_auth.oidc.OIDCBackend.verify_token")
def test_expired_token_is_rejected(self, mock_verify):
payload = self._valid_payload()
payload["exp"] = int(time.time()) - 120
mock_verify.return_value = payload
self.assertEqual(self.client.post(self.url, {"logout_token": "tok"}).status_code, 400)

@patch("basicbar_auth.oidc.OIDCBackend.verify_token")
def test_max_age_is_configurable(self, mock_verify):
payload = self._valid_payload()
payload["iat"] = int(time.time()) - 600
mock_verify.return_value = payload
with override_settings(OIDC_BACKCHANNEL_MAX_AGE=900):
self.assertEqual(self.client.post(self.url, {"logout_token": "tok"}).status_code, 200)

@patch("basicbar_auth.oidc.OIDCBackend.verify_token")
def test_valid_token_deletes_only_that_users_sessions(self, mock_verify):
mine = self._login(self.user)
Expand Down
2 changes: 1 addition & 1 deletion packages/django/basicbar-auth/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "basicbar-auth"
version = "0.1.0"
version = "0.1.1"
description = "OIDC-Fundament der virtUOS -bar-Tools: Backend, Silent Login, Back-Channel-Logout, Discovery, AbstractBasicUser, optionale Account-Limits"
readme = "README.md"
requires-python = ">=3.12"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
# Copyright 2026 Universität Osnabrück (virtUOS)

__version__ = "0.2.0"
__version__ = "0.2.2"
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# SPDX-License-Identifier: Apache-2.0
# Copyright 2026 Universität Osnabrück (virtUOS)

"""Shared bits for the stdlib-``urllib`` provider clients."""

from http.client import HTTPException
from urllib import error

# Everything a ``urlopen`` + ``read`` + ``json.loads`` round trip can raise.
# ``URLError``/``HTTPError``/``TimeoutError``/``ConnectionResetError`` are all
# ``OSError`` subclasses; ``IncompleteRead``/``RemoteDisconnected`` are
# ``HTTPException``; the rest covers a malformed or unexpectedly shaped body.
TRANSPORT_ERRORS = (OSError, HTTPException, ValueError, KeyError, IndexError, TypeError)

_MAX_DETAIL = 300


def http_error_detail(exc: error.HTTPError) -> str:
"""The first bytes of an HTTP error body — providers put the actual cause
there (``{"error": "en is not supported"}``), ``str(exc)`` only says
``HTTP Error 400: Bad Request``."""
try:
body = exc.read(_MAX_DETAIL + 1)
except Exception: # noqa: BLE001 — a body that can't be read adds nothing
return ""
text = body.decode("utf-8", errors="replace").strip()
if len(text) > _MAX_DETAIL:
text = text[:_MAX_DETAIL] + "…"
return text
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from urllib import error, request

from . import conf
from ._http import TRANSPORT_ERRORS, http_error_detail


class AIError(Exception):
Expand Down Expand Up @@ -72,5 +73,8 @@ def chat_json(system: str, user: str, *, max_tokens: int | None = None) -> Any:
if not content:
raise AIError("empty response from model")
return json.loads(content)
except (error.URLError, TimeoutError, ValueError, KeyError, IndexError, TypeError) as exc:
except error.HTTPError as exc:
detail = http_error_detail(exc)
raise AIError(f"AI request failed: {exc}" + (f" — {detail}" if detail else "")) from exc
except TRANSPORT_ERRORS as exc:
raise AIError(f"AI request failed: {exc}") from exc
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,50 @@ def test_disables_thinking_and_uses_configured_max_tokens(self):
def test_no_thinking_flag_when_disabled_off(self):
body = self._sent_payload()
self.assertNotIn("chat_template_kwargs", body)


class AiTransportErrorTests(SimpleTestCase):
"""Every failure mode of the HTTP round trip must surface as AIError, and
an HTTP error must carry the provider's explanation."""

SETTINGS = dict(
AI_PROVIDER="litellm", AI_BASE_URL="https://x/v1",
AI_API_KEY="k", AI_MODEL="qwen-3.5", AI_TIMEOUT=5,
)

def test_connection_reset_is_an_aierror_not_a_500(self):
with override_settings(**self.SETTINGS), patch(
"basicbar_integrations.ai.request.urlopen",
side_effect=ConnectionResetError("peer closed"),
):
with self.assertRaises(ai.AIError):
ai.chat_json("s", "u")

def test_incomplete_read_is_an_aierror(self):
from http.client import IncompleteRead

fake = MagicMock()
fake.read.side_effect = IncompleteRead(b"partial")
cm = MagicMock()
cm.__enter__.return_value = fake
with override_settings(**self.SETTINGS), patch(
"basicbar_integrations.ai.request.urlopen", return_value=cm
):
with self.assertRaises(ai.AIError):
ai.chat_json("s", "u")

def test_http_error_message_includes_provider_body(self):
from io import BytesIO
from urllib import error

http_error = error.HTTPError(
"https://x/v1/chat/completions", 400, "Bad Request", {},
BytesIO(b'{"error":{"message":"context length exceeded"}}'),
)
with override_settings(**self.SETTINGS), patch(
"basicbar_integrations.ai.request.urlopen", side_effect=http_error
):
with self.assertRaises(ai.AIError) as ctx:
ai.chat_json("s", "u")
self.assertIn("400", str(ctx.exception))
self.assertIn("context length exceeded", str(ctx.exception))
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,39 @@ def test_empty_reply_raises_translation_error(self):
):
with self.assertRaises(translation_service.TranslationError):
translation_service.translate("Hallo", "de", "en")


class TranslationTransportErrorTests(SimpleTestCase):
def test_connection_reset_is_a_translation_error(self):
with override_settings(**LT_ON), patch(
"basicbar_integrations.translation_service.request.urlopen",
side_effect=ConnectionResetError("peer closed"),
):
with self.assertRaises(translation_service.TranslationError):
translation_service.translate("Hallo", "de", "en")

def test_http_error_reports_status_and_provider_body(self):
from io import BytesIO

http_error = error.HTTPError(
"http://libretranslate.local/translate", 400, "Bad Request", {},
BytesIO(b'{"error":"en is not supported"}'),
)
with override_settings(**LT_ON), patch(
"basicbar_integrations.translation_service.request.urlopen",
side_effect=http_error,
):
with self.assertRaises(translation_service.TranslationError) as ctx:
translation_service.translate("Hallo", "de", "en")
message = str(ctx.exception)
self.assertIn("HTTP 400", message)
self.assertIn("en is not supported", message)
self.assertNotIn("unavailable", message)

def test_non_object_json_is_a_translation_error(self):
with override_settings(**LT_ON), patch(
"basicbar_integrations.translation_service.request.urlopen",
return_value=_mock_response('["not", "an", "object"]'),
):
with self.assertRaises(translation_service.TranslationError):
translation_service.translate("Hallo", "de", "en")
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from urllib import error, request

from . import conf
from ._http import TRANSPORT_ERRORS, http_error_detail


class TranslationError(Exception):
Expand Down Expand Up @@ -64,8 +65,18 @@ def _libretranslate(text: str, source: str, target: str, *, html: bool = False)
try:
with request.urlopen(req, timeout=15) as response:
data = json.loads(response.read().decode("utf-8"))
except (error.URLError, TimeoutError, ValueError) as exc:
except error.HTTPError as exc:
# A 4xx carries the actual cause in its body ("en is not supported");
# that is a configuration problem, not an outage.
detail = http_error_detail(exc)
raise TranslationError(
f"Translation service returned HTTP {exc.code}"
+ (f": {detail}" if detail else "")
) from exc
except TRANSPORT_ERRORS as exc:
raise TranslationError(f"Translation service unavailable: {exc}") from exc
if not isinstance(data, dict):
raise TranslationError("Translation service returned an unexpected response.")
translated = data.get("translatedText")
if not translated:
raise TranslationError("Translation service returned no text.")
Expand Down
2 changes: 1 addition & 1 deletion packages/django/basicbar-integrations/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "basicbar-integrations"
version = "0.2.1"
version = "0.2.2"
description = "Optionale Integrationen der virtUOS -bar-Tools: LibreTranslate, LiteLLM, Capabilities-Endpoint"
readme = "README.md"
requires-python = ">=3.12"
Expand Down
2 changes: 1 addition & 1 deletion packages/django/basicbar-lti/basicbar_lti/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
# Copyright 2026 Universität Osnabrück (virtUOS)

__version__ = "0.1.2"
__version__ = "0.1.4"
Loading
Loading