diff --git a/dashboard/src/shale.rs b/dashboard/src/shale.rs index b31aa4b8b6bb50797523d90064bb56ebf73f5ec1..b6d97956eed2e4bbbb4e4c5de556b15ef254bec8 100644 --- a/dashboard/src/shale.rs +++ b/dashboard/src/shale.rs @@ -706,7 +706,7 @@ pub async fn oauth(app: Arc, request: Request) -> Response { if parameters.get("code").is_none_or(|code| code.is_empty() || code.len() > 4096) || parameters.contains_key("error") { return Err(Error::new(400, "Shale sign-in was declined or incomplete. Start linking again.")); } - let identity = host::call(json!({"operation":"iam.request", "path":format!("/users/{}", string(&link["owner"])), "method":"GET", "body":null})).await?; + let identity = host::call(json!({"operation":"iam.request", "path":format!("/users/{}/shale-username", string(&link["owner"])), "method":"GET", "body":null})).await?; if identity["body"]["enabled"] != true { return Err(Error::new(403, "This dashboard account is disabled. Contact its administrator.")); } diff --git a/service/jellyfin/theme/theme.css b/service/jellyfin/theme/theme.css index b7ae91d02eda500877882cbc99d9d48c731390f0..016aec3785a92b186cc90b565f00cecf9b19733c 100644 --- a/service/jellyfin/theme/theme.css +++ b/service/jellyfin/theme/theme.css @@ -60,3 +60,6 @@ height: 100%; } .selectArrow { margin: 0; } + +html .backgroundContainer:not(.withBackdrop) { background-color: var(--main-background); } +html .backgroundContainer.withBackdrop { background-color: var(--main-background-transparent); } diff --git a/service/keycloak/provide.py b/service/keycloak/provide.py index e80ffbfdca0a6107f88767bef6c1e7d3174a3102..bc31a2cbe699ba539ed8de0f83e70d48f5a3d1f9 100644 --- a/service/keycloak/provide.py +++ b/service/keycloak/provide.py @@ -55,6 +55,45 @@ current = keycloak.request(f"{path}/{uuid}") attributes = {**(current.get("attributes") or {}), **desired["attributes"]} if any(current.get(key) != value for key, value in desired.items() if key != "attributes") or current.get("attributes", {}) != attributes: keycloak.request(f"{path}/{uuid}", "PUT", {**current, **desired, "attributes": attributes}) +aliases = request.get("usernameAliases", {}) +if aliases: + if len(set(aliases.values())) != len(aliases): + raise ValueError("Shale username aliases must be unique") + roles_path = f"{path}/{uuid}/roles" + roles = {role["name"]: role for role in keycloak.request(roles_path)} + if set(roles) - set(aliases.values()): + raise ValueError("username alias clients cannot also carry permission roles") + for username, alias in aliases.items(): + users = keycloak.request("/admin/realms/master/users?" + urllib.parse.urlencode({"username": username, "exact": "true"})) + if len(users) != 1: + raise ValueError(f"expected one alias account: {username}") + if alias not in roles: + keycloak.request(roles_path, "POST", {"name": alias, "description": "Shale username alias"}) + roles[alias] = keycloak.request(roles_path + "/" + urllib.parse.quote(alias, safe="")) + owners = keycloak.request(roles_path + "/" + urllib.parse.quote(alias, safe="") + "/users") + if any(owner["id"] != users[0]["id"] for owner in owners): + raise ValueError(f"username alias already assigned: {alias}") + assigned = keycloak.request(f"/admin/realms/master/users/{users[0]['id']}/role-mappings/clients/{uuid}") + if any(role["name"] != alias for role in assigned): + raise ValueError(f"multiple username aliases for {username}") + if not assigned: + keycloak.request(f"/admin/realms/master/users/{users[0]['id']}/role-mappings/clients/{uuid}", "POST", [roles[alias]]) + mapper = { + "name": "Shale username alias", "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-client-role-mapper", + "config": {"usermodel.clientRoleMapping.clientId": client_id, + "claim.name": "preferred_username", "jsonType.label": "String", + "multivalued": "false", "access.token.claim": "true", + "id.token.claim": "true", "userinfo.token.claim": "true"}, + } + mappers_path = f"{path}/{uuid}/protocol-mappers/models" + matches = [item for item in keycloak.request(mappers_path) if item["name"] == mapper["name"]] + if len(matches) > 1: + raise ValueError("duplicate Shale username mapper") + if not matches: + keycloak.request(mappers_path, "POST", mapper) + elif any(matches[0].get(key) != value for key, value in mapper.items()): + keycloak.request(f"{mappers_path}/{matches[0]['id']}", "PUT", {**mapper, "id": matches[0]["id"]}) secret = keycloak.request(f"{path}/{uuid}/client-secret")["value"] if not secret: raise ValueError(f"Keycloak client has no secret: {client_id}") diff --git a/service/keycloak/service.pkl b/service/keycloak/service.pkl index f181428932eff8fcc22264009835214dd7f48ac8..b8c4e3f829c0ddfc4706aa1c47c188a4409b8f8d 100644 --- a/service/keycloak/service.pkl +++ b/service/keycloak/service.pkl @@ -11,6 +11,7 @@ class OpenIDClient extends service.Requirement { clientId: String(isNotEmpty) name: String redirectUris: Listing = new { "*" } + usernameAliases: Map = new {} } local database = new postgres.Database { name = "keycloak_next" } diff --git a/service/shale/service.pkl b/service/shale/service.pkl index 188e8b9ac3b1494b73a76559275e91a351586e47..4553725bde93d6a4658f9a9d3426f2ef47bb8ce0 100644 --- a/service/shale/service.pkl +++ b/service/shale/service.pkl @@ -9,6 +9,7 @@ requirements { new keycloak.OpenIDClient { clientId = module.id name = module.meta.name + usernameAliases { ["snow"] = "clover" } } } diff --git a/tools/dashboard-run.py b/tools/dashboard-run.py index e604556033f5c424aceade9bb942d79a50062987..66daf37c1387c951973fdaf4408a3612d48f08eb 100644 --- a/tools/dashboard-run.py +++ b/tools/dashboard-run.py @@ -381,11 +381,12 @@ def iam_validate(request): "/roles": {"GET"}, "/users?max=1000": {"GET"}, "/users": {"POST"}, "": {"GET", "PUT", "DELETE"}, "/sessions": {"GET"}, "/credentials": {"GET"}, "/role-mappings/realm": {"GET", "POST", "DELETE"}, "/logout": {"POST"}, + "/shale-username": {"GET"}, "/execute-actions-email": {"PUT"}, "/reset-password": {"PUT"}, } user = re.fullmatch(r"/users/([0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12})(.*)", path) suffix = user[2] if user else path - if user and suffix not in {"", "/sessions", "/credentials", "/role-mappings/realm", "/logout", "/execute-actions-email", "/reset-password"}: + if user and suffix not in {"", "/sessions", "/credentials", "/role-mappings/realm", "/logout", "/execute-actions-email", "/reset-password", "/shale-username"}: raise Error(400, "Choose a supported user operation.") if path.startswith("/users?username="): try: @@ -398,7 +399,7 @@ def iam_validate(request): suffix = "/users?max=1000" if (method not in allowed.get(suffix, set()) or not user and suffix == "" or method in {"GET", "DELETE", "POST"} and suffix not in {"/users", "/role-mappings/realm"} and body is not None - or method == "GET" and body is not None): + or method == "GET" and body is not None or suffix == "/shale-username" and not user): raise Error(400, "Choose a supported user operation.") if method == "PUT" and suffix == "/reset-password": if (not isinstance(body, dict) or set(body) != {"type", "value", "temporary"} @@ -442,6 +443,15 @@ def iam(request): profile = client.request("/admin/realms/master/users/" + user[1]) if profile["username"] == "admin": raise Error(403, "The Keycloak administrator is managed outside the dashboard.") + if user[2] == "/shale-username": + clients = client.request("/admin/realms/master/clients?clientId=shale") + clients = [item for item in clients if item["clientId"] == "shale"] + if len(clients) != 1: + raise Error(502, "Shale's sign-in client is unavailable.") + aliases = client.request(f"/admin/realms/master/users/{user[1]}/role-mappings/clients/{clients[0]['id']}/composite") + if len(aliases) > 1: + raise Error(502, "This account has multiple Shale usernames.") + return {"body": {"username": aliases[0]["name"] if aliases else profile["username"], "enabled": profile["enabled"]}} if isinstance(body, dict) and "attributes" in body: attributes = dict(profile.get("attributes", {})) picture = body["attributes"]["picture"]