feat(proxy): restrict Transcribe media and output buckets per operator allowlist

Non-admin keys may only start transcription jobs whose media and transcript output live in the S3 buckets listed in general_settings.transcribe_media_buckets, and may not supply DataAccessRoleArn or JobExecutionSettings. The setting is editable from the Admin UI general settings table (new List editor) and DB values load into the running proxy when config.yaml does not set it

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
yassin 2026-09-18 01:18:07 +00:00
parent 4f8a5b5e12
commit 393d084db7
11 changed files with 296 additions and 10 deletions

View file

@ -20400,7 +20400,7 @@
},
"/transcribe/{operation}": {
"post": {
"description": "Pass-through for the Amazon Transcribe API, e.g. `POST /transcribe/StartTranscriptionJob`.\n\nThe request body is forwarded to the AWS JSON 1.1 API and signed with SigV4 using the\nproxy's AWS credentials. Standard jobs are tagged with the calling key's owner so that\nonly that owner (or a proxy admin) can read or delete them; account-wide operations\nsuch as ListTranscriptionJobs are limited to proxy admins. Streaming transcription\n(`transcribestreaming`) uses a separate HTTP/2 event-stream protocol and is not served\nby this route.\n\n[Docs](https://docs.litellm.ai/docs/pass_through/transcribe)",
"description": "Pass-through for the Amazon Transcribe API, e.g. `POST /transcribe/StartTranscriptionJob`.\n\nThe request body is forwarded to the AWS JSON 1.1 API and signed with SigV4 using the\nproxy's AWS credentials. Standard jobs are tagged with the calling key's owner so that\nonly that owner (or a proxy admin) can read or delete them, and keys other than proxy\nadmins may only read media from and write transcripts to the S3 buckets listed in\n`general_settings.transcribe_media_buckets`; account-wide operations\nsuch as ListTranscriptionJobs are limited to proxy admins. Streaming transcription\n(`transcribestreaming`) uses a separate HTTP/2 event-stream protocol and is not served\nby this route.\n\n[Docs](https://docs.litellm.ai/docs/pass_through/transcribe)",
"operationId": "transcribe_proxy_route_transcribe__operation__post",
"parameters": [
{

View file

@ -2784,6 +2784,10 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
default=None,
description="Serve the OpenAI pass-through WebSocket route, which relays frames to OpenAI under the proxy's own provider credential without reading them. Off by default.",
)
transcribe_media_buckets: list[str] | None = Field(
default=None,
description="S3 bucket names that keys other than proxy admins may read media from and write transcripts to through the Amazon Transcribe pass-through. Unset means only proxy admins can start transcription jobs.",
)
user_header_name: str | None = Field(
None,
description="[DEPRECATED] Use 'user_header_mappings' instead. When set, the header value is treated as the end user id unless overridden by user_header_mappings.",

View file

@ -1235,6 +1235,12 @@ async def bedrock_proxy_route(
COMPREHEND_MEDICAL_TARGET_PREFIX: Final = "ComprehendMedical_20181030"
def _proxy_general_settings() -> Mapping[str, object]:
from litellm.proxy.proxy_server import general_settings
return general_settings
def _resolve_aws_passthrough_region() -> str | None:
region_candidates: Final = (
get_secret_str(secret_name="AWS_REGION_NAME"),
@ -1361,13 +1367,16 @@ async def transcribe_proxy_route(
request: Request,
fastapi_response: Response,
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
general_settings: Annotated[Mapping[str, object], Depends(_proxy_general_settings)],
):
"""
Pass-through for the Amazon Transcribe API, e.g. `POST /transcribe/StartTranscriptionJob`.
The request body is forwarded to the AWS JSON 1.1 API and signed with SigV4 using the
proxy's AWS credentials. Standard jobs are tagged with the calling key's owner so that
only that owner (or a proxy admin) can read or delete them; account-wide operations
only that owner (or a proxy admin) can read or delete them, and keys other than proxy
admins may only read media from and write transcripts to the S3 buckets listed in
`general_settings.transcribe_media_buckets`; account-wide operations
such as ListTranscriptionJobs are limited to proxy admins. Streaming transcription
(`transcribestreaming`) uses a separate HTTP/2 event-stream protocol and is not served
by this route.
@ -1384,7 +1393,9 @@ async def transcribe_proxy_route(
transcribe_cost_per_second,
transcribe_job_access_refusal,
transcribe_job_lookup,
transcribe_media_buckets,
transcribe_owned_start_request,
transcribe_storage_refusal,
transcribe_supported_operations,
transcribe_unpriceable_request_reason,
)
@ -1420,6 +1431,13 @@ async def transcribe_proxy_route(
admin_only_refusal: Final = transcribe_admin_only_refusal(operation, user_api_key_dict)
if admin_only_refusal is not None:
raise HTTPException(status_code=admin_only_refusal.status_code, detail=admin_only_refusal.detail)
storage_refusal: Final = (
transcribe_storage_refusal(data, transcribe_media_buckets(general_settings), user_api_key_dict)
if operation == TRANSCRIBE_PRICED_OPERATION
else None
)
if storage_refusal is not None:
raise HTTPException(status_code=storage_refusal.status_code, detail=storage_refusal.detail)
request_body: Final = (
transcribe_owned_start_request(data, user_api_key_dict) if operation == TRANSCRIBE_PRICED_OPERATION else data
)
@ -1472,6 +1490,7 @@ async def transcribe_sdk_proxy_route(
request: Request,
fastapi_response: Response,
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
general_settings: Annotated[Mapping[str, object], Depends(_proxy_general_settings)],
):
"""
AWS-SDK-shaped pass-through for Amazon Transcribe: point the SDK's `endpoint_url`
@ -1496,6 +1515,7 @@ async def transcribe_sdk_proxy_route(
request=request,
fastapi_response=fastapi_response,
user_api_key_dict=user_api_key_dict,
general_settings=general_settings,
)
@ -2770,12 +2790,6 @@ class _OpenAIWebsocketRelay(Protocol):
) -> None: ...
def _proxy_general_settings() -> Mapping[str, object]:
from litellm.proxy.proxy_server import general_settings
return general_settings
def _openai_websocket_relay() -> _OpenAIWebsocketRelay:
return websocket_passthrough_request

View file

@ -60,6 +60,9 @@ TRANSCRIBE_TERMINAL_JOB_STATUSES: Final = frozenset({"COMPLETED", "FAILED"})
TRANSCRIBE_MISSING_JOB_ERRORS: Final = frozenset({"BadRequestException", "NotFoundException"})
TRANSCRIBE_OWNER_TAG: Final = "litellm-owner"
TRANSCRIBE_OWNED_JOB_OPERATIONS: Final = frozenset({"GetTranscriptionJob", "DeleteTranscriptionJob"})
TRANSCRIBE_MEDIA_BUCKETS_SETTING: Final = "transcribe_media_buckets"
TRANSCRIBE_ROLE_MEMBERS: Final = ("DataAccessRoleArn", "JobExecutionSettings")
TRANSCRIBE_MEDIA_URI_MEMBERS: Final = ("MediaFileUri", "RedactedMediaFileUri")
JobLookup: TypeAlias = Callable[[str], Awaitable[Mapping[str, object]]] # mutable-ok: Callable parameter syntax
MediaDurationProbe: TypeAlias = Callable[[str, float], Awaitable[float | None]] # mutable-ok: Callable parameter syntax
@ -109,6 +112,7 @@ class _PricedCostMapEntry(BaseModel):
_JSON_OBJECT: Final = TypeAdapter(Mapping[str, object])
_JSON_OBJECTS: Final = TypeAdapter(tuple[Mapping[str, object], ...])
_BUCKET_NAMES: Final = TypeAdapter(frozenset[str])
@dataclass(frozen=True, slots=True)
@ -231,6 +235,68 @@ def transcribe_admin_only_refusal(operation: str, user_api_key_dict: UserAPIKeyA
)
def transcribe_media_buckets(general_settings: Mapping[str, object]) -> frozenset[str] | None:
try:
return _BUCKET_NAMES.validate_python(general_settings.get(TRANSCRIBE_MEDIA_BUCKETS_SETTING))
except ValidationError:
return None
def s3_bucket_name(uri: object) -> str | None:
if not isinstance(uri, str) or not uri.startswith("s3://"):
return None
bucket, _, _ = uri.removeprefix("s3://").partition("/")
return bucket or None
def transcribe_storage_refusal(
request_body: Mapping[str, object],
allowed_buckets: frozenset[str] | None,
user_api_key_dict: UserAPIKeyAuth,
) -> TranscribeRefusal | None:
"""
Transcribe reads the media and writes the transcript with the proxy's own AWS credentials, so a
non-admin key may only point a job at buckets the operator listed; otherwise any object those
credentials can reach could be transcribed and read back through the caller's own job.
"""
if is_proxy_admin(user_api_key_dict):
return None
if allowed_buckets is None:
return TranscribeRefusal(
403,
f"general_settings.{TRANSCRIBE_MEDIA_BUCKETS_SETTING} is not a list of S3 bucket names, so only a proxy"
f" admin may {TRANSCRIBE_PRICED_OPERATION}; list the buckets other keys may read media from and write"
" transcripts to",
)
roles: Final = tuple(m for m in TRANSCRIBE_ROLE_MEMBERS if m in request_body)
if roles:
return TranscribeRefusal(
403,
f"{', '.join(roles)} would run the job under a role other than the proxy's own AWS credentials, so"
" only a proxy admin may set it",
)
media: Final = request_body.get("Media")
media_uris: Final = (
tuple((f"Media.{m}", s3_bucket_name(media.get(m))) for m in TRANSCRIBE_MEDIA_URI_MEMBERS if m in media)
if isinstance(media, Mapping)
else ()
)
output: Final = request_body.get("OutputBucketName")
locations: Final = media_uris + (
(("OutputBucketName", output if isinstance(output, str) else None),)
if "OutputBucketName" in request_body
else ()
)
offending: Final = tuple(member for member, bucket in locations if bucket not in allowed_buckets)
if offending:
return TranscribeRefusal(
403,
f"{', '.join(offending)} must name one of the S3 buckets in general_settings."
f"{TRANSCRIBE_MEDIA_BUCKETS_SETTING} ({', '.join(sorted(allowed_buckets))}), as s3://bucket/key for media",
)
return None
def transcribe_owned_start_request(
request_body: Mapping[str, object], user_api_key_dict: UserAPIKeyAuth
) -> dict[str, object] | TranscribeRefusal:

View file

@ -7108,6 +7108,9 @@ class ProxyConfig:
if "blocked_file_extensions" not in self._yaml_general_settings_keys:
general_settings["blocked_file_extensions"] = _general_settings.get("blocked_file_extensions")
if "transcribe_media_buckets" not in self._yaml_general_settings_keys:
general_settings["transcribe_media_buckets"] = _general_settings.get("transcribe_media_buckets")
## ALERTING ARGS ##
if "alerting_args" in _general_settings:
general_settings["alerting_args"] = _general_settings["alerting_args"]
@ -17146,6 +17149,7 @@ _GENERAL_SETTINGS_CONFIG_LIST_FIELD_TYPES: Final[Mapping[str, str]] = MappingPro
"disable_auto_add_proxy_admin_to_teams": "Boolean",
"apply_user_budget_to_team_keys": "Boolean",
"user_api_key_cache_max_size": "Integer",
"transcribe_media_buckets": "List",
}
)

View file

@ -25,7 +25,9 @@ from litellm.proxy.pass_through_endpoints.llm_provider_handlers.transcribe_passt
transcribe_admin_only_refusal,
transcribe_cost_per_second,
transcribe_job_access_refusal,
transcribe_media_buckets,
transcribe_owned_start_request,
transcribe_storage_refusal,
transcribe_supported_operations,
transcribe_unpriceable_request_reason,
write_media_within_limit,
@ -503,6 +505,84 @@ class TestTranscribeAdminOnlyRefusal:
assert transcribe_admin_only_refusal(operation, ADMIN_KEY) is None
ALLOWED_BUCKETS = frozenset({"tenant-media", "tenant-transcripts"})
def _start_body(media_uri: str = "s3://tenant-media/call.wav", **members: object) -> dict[str, object]:
return {"TranscriptionJobName": "j", "Media": {"MediaFileUri": media_uri}, **members}
class TestTranscribeMediaBuckets:
def test_a_list_of_bucket_names_is_read_from_general_settings(self):
assert transcribe_media_buckets({"transcribe_media_buckets": ["a", "b"]}) == frozenset({"a", "b"})
@pytest.mark.parametrize("settings", [{}, {"transcribe_media_buckets": "a"}, {"transcribe_media_buckets": [1]}])
def test_a_missing_or_malformed_setting_reads_as_unset(self, settings: dict[str, object]):
assert transcribe_media_buckets(settings) is None
class TestTranscribeStorageRefusal:
def test_media_and_output_in_listed_buckets_are_allowed(self):
body = _start_body(OutputBucketName="tenant-transcripts", OutputKey="out/")
assert transcribe_storage_refusal(body, ALLOWED_BUCKETS, VIRTUAL_KEY) is None
@pytest.mark.parametrize(
"media_uri",
[
"s3://other-tenant/call.wav",
"https://tenant-media.s3.us-west-2.amazonaws.com/call.wav",
"s3://",
],
)
def test_media_outside_the_listed_buckets_is_refused(self, media_uri: str):
refusal = transcribe_storage_refusal(_start_body(media_uri), ALLOWED_BUCKETS, VIRTUAL_KEY)
assert refusal is not None
assert refusal.status_code == 403
assert "Media.MediaFileUri" in refusal.detail
def test_redacted_media_outside_the_listed_buckets_is_refused(self):
body = {
"TranscriptionJobName": "j",
"Media": {"MediaFileUri": "s3://tenant-media/call.wav", "RedactedMediaFileUri": "s3://other-tenant/c.wav"},
}
refusal = transcribe_storage_refusal(body, ALLOWED_BUCKETS, VIRTUAL_KEY)
assert refusal is not None
assert "Media.RedactedMediaFileUri" in refusal.detail
@pytest.mark.parametrize("output", ["other-tenant", 7])
def test_an_output_bucket_outside_the_listed_buckets_is_refused(self, output: object):
refusal = transcribe_storage_refusal(_start_body(OutputBucketName=output), ALLOWED_BUCKETS, VIRTUAL_KEY)
assert refusal is not None
assert refusal.status_code == 403
assert "OutputBucketName" in refusal.detail
@pytest.mark.parametrize("member", ["DataAccessRoleArn", "JobExecutionSettings"])
def test_a_caller_chosen_role_is_refused(self, member: str):
refusal = transcribe_storage_refusal(_start_body(**{member: "x"}), ALLOWED_BUCKETS, VIRTUAL_KEY)
assert refusal is not None
assert refusal.status_code == 403
assert member in refusal.detail
def test_an_unset_bucket_list_refuses_virtual_keys(self):
refusal = transcribe_storage_refusal(_start_body(), None, VIRTUAL_KEY)
assert refusal is not None
assert refusal.status_code == 403
assert "transcribe_media_buckets" in refusal.detail
@pytest.mark.parametrize("allowed", [None, ALLOWED_BUCKETS])
def test_proxy_admins_are_not_restricted(self, allowed: frozenset[str] | None):
body = _start_body("s3://other-tenant/call.wav", DataAccessRoleArn="arn:aws:iam::1:role/r")
assert transcribe_storage_refusal(body, allowed, ADMIN_KEY) is None
class TestTranscribeOwnedStartRequest:
def test_the_caller_identity_is_appended_to_the_job_tags(self):
body = {"TranscriptionJobName": "j", "Tags": [{"Key": "env", "Value": "qa"}]}

View file

@ -29,6 +29,7 @@ from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import (
BaseOpenAIPassThroughHandler,
RouteChecks,
_join_url_paths,
_proxy_general_settings,
anthropic_proxy_route,
azure_proxy_route,
bedrock_llm_proxy_route,
@ -5292,6 +5293,9 @@ def transcribe_client(monkeypatch: pytest.MonkeyPatch) -> Iterator[TestClient]:
monkeypatch.setitem(
app.dependency_overrides, user_api_key_auth, lambda: UserAPIKeyAuth(api_key="sk-virtual", user_id="user-a")
)
monkeypatch.setitem(
app.dependency_overrides, _proxy_general_settings, lambda: {"transcribe_media_buckets": ["bucket"]}
)
yield TestClient(app)
@ -5333,6 +5337,46 @@ class TestTranscribeProxyRoute:
assert "/us-west-2/transcribe/aws4_request" in sent.headers["authorization"]
assert "x-amz-date" in sent.headers
@pytest.mark.parametrize(
"body, member",
[
({"Media": {"MediaFileUri": "s3://other-tenant/audio.wav"}}, "Media.MediaFileUri"),
({"OutputBucketName": "other-tenant"}, "OutputBucketName"),
({"DataAccessRoleArn": "arn:aws:iam::123456789012:role/reader"}, "DataAccessRoleArn"),
],
)
def test_storage_outside_the_listed_buckets_is_refused_before_signing(
self, transcribe_client: TestClient, body: dict[str, object], member: str
) -> None:
with respx.mock(assert_all_called=False) as upstream:
route = upstream.post(TRANSCRIBE_UPSTREAM)
response = transcribe_client.post("/transcribe/StartTranscriptionJob", json={**dict(self.START_JOB_BODY), **body})
assert response.status_code == 403
assert member in response.json()["detail"]
assert not route.called
def test_start_needs_a_bucket_list_unless_the_caller_is_a_proxy_admin(
self, transcribe_client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
from litellm.proxy.proxy_server import app
monkeypatch.setitem(app.dependency_overrides, _proxy_general_settings, lambda: {})
with respx.mock(assert_all_called=False) as upstream:
route = upstream.post(TRANSCRIBE_UPSTREAM).mock(return_value=httpx.Response(200, json=_owned_job("admin")))
refused = transcribe_client.post("/transcribe/StartTranscriptionJob", json=dict(self.START_JOB_BODY))
monkeypatch.setitem(
app.dependency_overrides,
user_api_key_auth,
lambda: UserAPIKeyAuth(api_key="sk-admin", user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN),
)
allowed = transcribe_client.post("/transcribe/StartTranscriptionJob", json=dict(self.START_JOB_BODY))
assert refused.status_code == 403
assert "transcribe_media_buckets" in refused.json()["detail"]
assert allowed.status_code == 200
assert route.calls[0].request.headers["x-amz-target"] == "Transcribe.StartTranscriptionJob"
def test_the_caller_cannot_forge_the_owner_tag(self, transcribe_client: TestClient) -> None:
with respx.mock(assert_all_called=False) as upstream:
route = upstream.post(TRANSCRIBE_UPSTREAM)

View file

@ -3508,6 +3508,27 @@ async def test_ProxyConfig__update_general_settings_yaml_allowed_file_extensions
assert ps.general_settings.get("allowed_file_extensions") == [".pdf"]
@pytest.mark.asyncio
async def test_ProxyConfig__update_general_settings_applies_db_transcribe_media_buckets(monkeypatch):
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {})
pc = ProxyConfig()
await pc._update_general_settings({"transcribe_media_buckets": ["team-audio"]})
from litellm.proxy import proxy_server as ps
assert ps.general_settings.get("transcribe_media_buckets") == ["team-audio"]
@pytest.mark.asyncio
async def test_ProxyConfig__update_general_settings_yaml_transcribe_media_buckets_wins_over_db(monkeypatch):
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {"transcribe_media_buckets": ["yaml-audio"]})
pc = ProxyConfig()
pc._yaml_general_settings_keys = {"transcribe_media_buckets"}
await pc._update_general_settings({"transcribe_media_buckets": ["team-audio"]})
from litellm.proxy import proxy_server as ps
assert ps.general_settings.get("transcribe_media_buckets") == ["yaml-audio"]
@pytest.mark.asyncio
async def test_ProxyConfig__update_general_settings_none_input_noop():
pc = ProxyConfig()

View file

@ -1,4 +1,4 @@
import { renderWithProviders, screen, within } from "../../../../../tests/test-utils";
import { fireEvent, renderWithProviders, screen, within } from "../../../../../tests/test-utils";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import GeneralSettings from "./general_settings";
@ -159,6 +159,31 @@ describe("GeneralSettings tabs", () => {
});
});
it("persists a List setting typed as comma-separated text as a trimmed string array", async () => {
vi.mocked(getGeneralSettingsCall).mockResolvedValue([
{
field_name: "transcribe_media_buckets",
field_type: "List",
field_value: ["old-bucket"],
field_description: "buckets",
stored_in_db: true,
},
]);
vi.mocked(updateConfigFieldSetting).mockClear();
const user = userEvent.setup();
renderWithProviders(<GeneralSettings accessToken="token" userRole="Admin" userID="user" />);
await user.click(screen.getByRole("tab", { name: "General" }));
const input = await screen.findByRole("textbox", { name: "transcribe_media_buckets" });
expect(input).toHaveValue("old-bucket");
fireEvent.change(input, { target: { value: " team-audio, shared.audio ,, " } });
await user.click(
within(screen.getByRole("row", { name: /transcribe_media_buckets/ })).getByRole("button", { name: "Update" }),
);
expect(vi.mocked(updateConfigFieldSetting).mock.calls).toEqual([
["token", "transcribe_media_buckets", ["team-audio", "shared.audio"]],
]);
});
it("should delete only the Default setting and retain explicit false and zero", async () => {
vi.mocked(getGeneralSettingsCall).mockResolvedValue([
{

View file

@ -43,6 +43,16 @@ const NUMERIC_INPUT_WIDTH = "w-36";
const toNumericValue = (raw: string): number | null => (raw === "" ? null : Number(raw));
const toListValue = (raw: string): string[] | null => {
const items = raw
.split(",")
.map((item) => item.trim())
.filter((item) => item !== "");
return items.length === 0 ? null : items;
};
const fromListValue = (value: unknown): string => (Array.isArray(value) ? value.join(", ") : "");
const SettingValueEditor: React.FC<{
setting: generalSettingsItem;
onChange: (fieldName: string, newValue: any) => void;
@ -93,6 +103,17 @@ const SettingValueEditor: React.FC<{
</InputGroup>
);
}
if (setting.field_type === "List") {
return (
<Input
key={String(setting.stored_in_db)}
aria-label={setting.field_name}
placeholder="Comma-separated values"
defaultValue={fromListValue(setting.field_value)}
onChange={(event) => onChange(setting.field_name, toListValue(event.target.value))}
/>
);
}
if (setting.field_type === "Select") {
return (
<Select value={setting.field_value ?? null} onValueChange={(newValue) => onChange(setting.field_name, newValue)}>

View file

@ -16515,7 +16515,9 @@ export interface paths {
*
* The request body is forwarded to the AWS JSON 1.1 API and signed with SigV4 using the
* proxy's AWS credentials. Standard jobs are tagged with the calling key's owner so that
* only that owner (or a proxy admin) can read or delete them; account-wide operations
* only that owner (or a proxy admin) can read or delete them, and keys other than proxy
* admins may only read media from and write transcripts to the S3 buckets listed in
* `general_settings.transcribe_media_buckets`; account-wide operations
* such as ListTranscriptionJobs are limited to proxy admins. Streaming transcription
* (`transcribestreaming`) uses a separate HTTP/2 event-stream protocol and is not served
* by this route.
@ -26859,6 +26861,11 @@ export interface components {
* @description Fine-grained control over which object types to load from the database when store_model_in_db is True. Available types: 'models', 'mcp', 'guardrails', 'vector_stores', 'pass_through_endpoints', 'prompts', 'model_cost_map', 'tools', 'config_overrides'. If not set, all objects are loaded (default behavior).
*/
supported_db_objects?: components["schemas"]["SupportedDBObjectType"][] | null;
/**
* Transcribe Media Buckets
* @description S3 bucket names that keys other than proxy admins may read media from and write transcripts to through the Amazon Transcribe pass-through. Unset means only proxy admins can start transcription jobs.
*/
transcribe_media_buckets?: string[] | null;
/**
* Trusted Proxy Ranges
* @description CIDR ranges of trusted reverse proxies allowed to provide identity headers for header-based auth paths such as enable_oauth2_proxy_auth and custom_ui_sso_sign_in_handler.