diff --git a/scripts/livepass.py b/scripts/livepass.py index 1e683dff..587ece31 100755 --- a/scripts/livepass.py +++ b/scripts/livepass.py @@ -501,6 +501,7 @@ CONSOLE_TEMPLATE = """ auth_grant_profile: "entra", dynamic_auth_modes: ["entra_app", "entra_obo", "rfc8693_obo"], scopes_auth_modes: ["rfc8693_obo"], + app_identity_auth_modes: ["entra_app"], auth_mode_profiles: { entra_app: "entra", entra_obo: "entra", rfc8693_obo: "rfc8693", diff --git a/tests/test_model_provider_obo.py b/tests/test_model_provider_obo.py index e5052fdc..af7be292 100644 --- a/tests/test_model_provider_obo.py +++ b/tests/test_model_provider_obo.py @@ -1640,9 +1640,7 @@ class TestModelOboToken: """A delegated mode with no registered grant-profile pairing cannot pin a leg, so the dispatch refuses loudly before the mint bridge — minting with leg=None would run the pre-dedicated-mode overload.""" - import turnstone.core.session as session_module - - monkeypatch.setattr(session_module, "MODEL_AUTH_MODE_PROFILES", {}) + monkeypatch.setattr("turnstone.core.session.MODEL_AUTH_MODE_PROFILES", {}) reg = _registry_with(self._obo_cfg()) sess = _fake_session(registry=reg, user_id=USER, mint_token="never") with pytest.raises(BackendAuthUnavailableError, match="grant-profile pairing"): diff --git a/tests/test_model_registry.py b/tests/test_model_registry.py index 79ed57d6..814ac2db 100644 --- a/tests/test_model_registry.py +++ b/tests/test_model_registry.py @@ -2982,14 +2982,17 @@ def test_obo_scopes_normalizers_agree_across_modules() -> None: assert mr_module.sanitize_backend_auth_scopes(dirty) == "aud-gwopenid" # The C0 separator block counts as Python whitespace, so a bare # str.split() would swallow it before the guard could refuse; the - # registry must refuse it like every other control byte, while the - # sanctioned separators (tab/newline/CR, blessed in the corpus above) - # keep collapsing. + # registry must refuse it like every other control byte, and the + # SANITIZE must strip it like every other control — never promote it to + # a separator that splits one token into two valid-looking scopes — + # while the sanctioned separators (tab/newline/CR, blessed in the + # corpus above) keep collapsing. for sep_byte in (chr(0x1C), chr(0x1D), chr(0x1E), chr(0x1F)): with pytest.raises(mr_module.ModelAuthConfigError): mr_module._normalize_auth_mode( "gw", "rfc8693_obo", "api://gw", f"aud-gw{sep_byte}openid" ) + assert mr_module.sanitize_backend_auth_scopes(f"aud-gw{sep_byte}openid") == "aud-gwopenid" def test_control_bearing_alias_refuses_to_load() -> None: diff --git a/turnstone/core/model_registry.py b/turnstone/core/model_registry.py index d77b63a6..a0f3e187 100644 --- a/turnstone/core/model_registry.py +++ b/turnstone/core/model_registry.py @@ -269,18 +269,22 @@ def strip_control_characters(value: str) -> str: def sanitize_backend_auth_scopes(value: Any) -> str: """The ONE spelling of the backend-auth scopes sanitize. - Collapse whitespace runs to single spaces, strip the remaining C0/DEL - control characters, then re-collapse the runs the stripping can reopen - (``"a \\x01 b"`` becomes ``"a b"`` becomes ``"a b"``). Collapse-first - ordering is load-bearing: stripping a tab-separated list first would - CONCATENATE the scopes the tab separates. No length cap and no refusal — - policy (caps, and refuse-vs-strip on garbage) stays with each consuming + The sanctioned separators — tab, newline, CR — read as spaces FIRST + (stripping a tab-separated list outright would CONCATENATE the scopes + the tab separates), then every remaining C0/DEL control character is + stripped, and finally whitespace runs collapse to single spaces. The + separator vocabulary deliberately matches the registry guard's: the C0 + separator block (U+001C–U+001F) is a CONTROL here, never a separator — + a bare ``str.split()`` would silently promote it to one — so a control + byte inside a token strips-and-joins rather than splitting the token + into two valid-looking scopes. No length cap and no refusal — policy + (caps, and refuse-vs-strip on garbage) stays with each consuming layer; this function only fixes the shared spelling those policies measure, so the console store, the registry load, and the mint request can never disagree on what a scopes value *is*. """ - collapsed = " ".join(str(value or "").split()) - return " ".join(strip_control_characters(collapsed).split()) + blessed = re.sub(r"[\t\n\r]", " ", str(value or "")) + return " ".join(strip_control_characters(blessed).split()) def _check_auth_text(alias: str, field: str, value: str) -> None: