authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-04 21:48:47-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-04 21:58:35-07:00
logb75574032f70c6b0613824112a4959f12e6ab028
treecbe561ed11dbc4c0c26c5860056d7a0ffb8058db
parent5e5766a9141ea0439e3e4b414e88749d908626c5
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

Preserve Shale account aliases across sign-in

Map snow to ~clover only for Shale, preserving the identity provider subject. Verify MCP account linking against the effective client alias. Override Jellyfin backgrounds after its stock skin loads. Assisted-by: gpt-6.1-sol

6 files changed, 57 insertions(+), 3 deletions(-)

dashboard/src/shale.rs+1-1
...@@ -706,7 +706,7 @@ pub async fn oauth(app: Arc<App>, request: Request) -> Response {...@@ -706,7 +706,7 @@ pub async fn oauth(app: Arc<App>, request: Request) -> Response {
706 if parameters.get("code").is_none_or(|code| code.is_empty() || code.len() > 4096) || parameters.contains_key("error") {706 if parameters.get("code").is_none_or(|code| code.is_empty() || code.len() > 4096) || parameters.contains_key("error") {
707 return Err(Error::new(400, "Shale sign-in was declined or incomplete. Start linking again."));707 return Err(Error::new(400, "Shale sign-in was declined or incomplete. Start linking again."));
708 }708 }
709 let identity = host::call(json!({"operation":"iam.request", "path":format!("/users/{}", string(&link["owner"])), "method":"GET", "body":null})).await?;709 let identity = host::call(json!({"operation":"iam.request", "path":format!("/users/{}/shale-username", string(&link["owner"])), "method":"GET", "body":null})).await?;
710 if identity["body"]["enabled"] != true {710 if identity["body"]["enabled"] != true {
711 return Err(Error::new(403, "This dashboard account is disabled. Contact its administrator."));711 return Err(Error::new(403, "This dashboard account is disabled. Contact its administrator."));
712 }712 }
service/jellyfin/theme/theme.css+3
...@@ -60,3 +60,6 @@...@@ -60,3 +60,6 @@
60 height: 100%;60 height: 100%;
61 }61 }
62 .selectArrow { margin: 0; }62 .selectArrow { margin: 0; }
63
64html .backgroundContainer:not(.withBackdrop) { background-color: var(--main-background); }
65html .backgroundContainer.withBackdrop { background-color: var(--main-background-transparent); }
service/keycloak/provide.py+39
...@@ -55,6 +55,45 @@ current = keycloak.request(f"{path}/{uuid}")...@@ -55,6 +55,45 @@ current = keycloak.request(f"{path}/{uuid}")
55attributes = {**(current.get("attributes") or {}), **desired["attributes"]}55attributes = {**(current.get("attributes") or {}), **desired["attributes"]}
56if any(current.get(key) != value for key, value in desired.items() if key != "attributes") or current.get("attributes", {}) != attributes:56if any(current.get(key) != value for key, value in desired.items() if key != "attributes") or current.get("attributes", {}) != attributes:
57 keycloak.request(f"{path}/{uuid}", "PUT", {**current, **desired, "attributes": attributes})57 keycloak.request(f"{path}/{uuid}", "PUT", {**current, **desired, "attributes": attributes})
58aliases = request.get("usernameAliases", {})
59if aliases:
60 if len(set(aliases.values())) != len(aliases):
61 raise ValueError("Shale username aliases must be unique")
62 roles_path = f"{path}/{uuid}/roles"
63 roles = {role["name"]: role for role in keycloak.request(roles_path)}
64 if set(roles) - set(aliases.values()):
65 raise ValueError("username alias clients cannot also carry permission roles")
66 for username, alias in aliases.items():
67 users = keycloak.request("/admin/realms/master/users?" + urllib.parse.urlencode({"username": username, "exact": "true"}))
68 if len(users) != 1:
69 raise ValueError(f"expected one alias account: {username}")
70 if alias not in roles:
71 keycloak.request(roles_path, "POST", {"name": alias, "description": "Shale username alias"})
72 roles[alias] = keycloak.request(roles_path + "/" + urllib.parse.quote(alias, safe=""))
73 owners = keycloak.request(roles_path + "/" + urllib.parse.quote(alias, safe="") + "/users")
74 if any(owner["id"] != users[0]["id"] for owner in owners):
75 raise ValueError(f"username alias already assigned: {alias}")
76 assigned = keycloak.request(f"/admin/realms/master/users/{users[0]['id']}/role-mappings/clients/{uuid}")
77 if any(role["name"] != alias for role in assigned):
78 raise ValueError(f"multiple username aliases for {username}")
79 if not assigned:
80 keycloak.request(f"/admin/realms/master/users/{users[0]['id']}/role-mappings/clients/{uuid}", "POST", [roles[alias]])
81 mapper = {
82 "name": "Shale username alias", "protocol": "openid-connect",
83 "protocolMapper": "oidc-usermodel-client-role-mapper",
84 "config": {"usermodel.clientRoleMapping.clientId": client_id,
85 "claim.name": "preferred_username", "jsonType.label": "String",
86 "multivalued": "false", "access.token.claim": "true",
87 "id.token.claim": "true", "userinfo.token.claim": "true"},
88 }
89 mappers_path = f"{path}/{uuid}/protocol-mappers/models"
90 matches = [item for item in keycloak.request(mappers_path) if item["name"] == mapper["name"]]
91 if len(matches) > 1:
92 raise ValueError("duplicate Shale username mapper")
93 if not matches:
94 keycloak.request(mappers_path, "POST", mapper)
95 elif any(matches[0].get(key) != value for key, value in mapper.items()):
96 keycloak.request(f"{mappers_path}/{matches[0]['id']}", "PUT", {**mapper, "id": matches[0]["id"]})
58secret = keycloak.request(f"{path}/{uuid}/client-secret")["value"]97secret = keycloak.request(f"{path}/{uuid}/client-secret")["value"]
59if not secret:98if not secret:
60 raise ValueError(f"Keycloak client has no secret: {client_id}")99 raise ValueError(f"Keycloak client has no secret: {client_id}")
service/keycloak/service.pkl+1
...@@ -11,6 +11,7 @@ class OpenIDClient extends service.Requirement {...@@ -11,6 +11,7 @@ class OpenIDClient extends service.Requirement {
11 clientId: String(isNotEmpty)11 clientId: String(isNotEmpty)
12 name: String12 name: String
13 redirectUris: Listing<String> = new { "*" }13 redirectUris: Listing<String> = new { "*" }
14 usernameAliases: Map<String, String> = new {}
14}15}
1516
16local database = new postgres.Database { name = "keycloak_next" }17local database = new postgres.Database { name = "keycloak_next" }
service/shale/service.pkl+1
...@@ -9,6 +9,7 @@ requirements {...@@ -9,6 +9,7 @@ requirements {
9 new keycloak.OpenIDClient {9 new keycloak.OpenIDClient {
10 clientId = module.id10 clientId = module.id
11 name = module.meta.name11 name = module.meta.name
12 usernameAliases { ["snow"] = "clover" }
12 }13 }
13}14}
1415
tools/dashboard-run.py+12-2
...@@ -381,11 +381,12 @@ def iam_validate(request):...@@ -381,11 +381,12 @@ def iam_validate(request):
381 "/roles": {"GET"}, "/users?max=1000": {"GET"}, "/users": {"POST"},381 "/roles": {"GET"}, "/users?max=1000": {"GET"}, "/users": {"POST"},
382 "": {"GET", "PUT", "DELETE"}, "/sessions": {"GET"}, "/credentials": {"GET"},382 "": {"GET", "PUT", "DELETE"}, "/sessions": {"GET"}, "/credentials": {"GET"},
383 "/role-mappings/realm": {"GET", "POST", "DELETE"}, "/logout": {"POST"},383 "/role-mappings/realm": {"GET", "POST", "DELETE"}, "/logout": {"POST"},
384 "/shale-username": {"GET"},
384 "/execute-actions-email": {"PUT"}, "/reset-password": {"PUT"},385 "/execute-actions-email": {"PUT"}, "/reset-password": {"PUT"},
385 }386 }
386 user = re.fullmatch(r"/users/([0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12})(.*)", path)387 user = re.fullmatch(r"/users/([0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12})(.*)", path)
387 suffix = user[2] if user else path388 suffix = user[2] if user else path
388 if user and suffix not in {"", "/sessions", "/credentials", "/role-mappings/realm", "/logout", "/execute-actions-email", "/reset-password"}:389 if user and suffix not in {"", "/sessions", "/credentials", "/role-mappings/realm", "/logout", "/execute-actions-email", "/reset-password", "/shale-username"}:
389 raise Error(400, "Choose a supported user operation.")390 raise Error(400, "Choose a supported user operation.")
390 if path.startswith("/users?username="):391 if path.startswith("/users?username="):
391 try:392 try:
...@@ -398,7 +399,7 @@ def iam_validate(request):...@@ -398,7 +399,7 @@ def iam_validate(request):
398 suffix = "/users?max=1000"399 suffix = "/users?max=1000"
399 if (method not in allowed.get(suffix, set()) or not user and suffix == ""400 if (method not in allowed.get(suffix, set()) or not user and suffix == ""
400 or method in {"GET", "DELETE", "POST"} and suffix not in {"/users", "/role-mappings/realm"} and body is not None401 or method in {"GET", "DELETE", "POST"} and suffix not in {"/users", "/role-mappings/realm"} and body is not None
401 or method == "GET" and body is not None):402 or method == "GET" and body is not None or suffix == "/shale-username" and not user):
402 raise Error(400, "Choose a supported user operation.")403 raise Error(400, "Choose a supported user operation.")
403 if method == "PUT" and suffix == "/reset-password":404 if method == "PUT" and suffix == "/reset-password":
404 if (not isinstance(body, dict) or set(body) != {"type", "value", "temporary"}405 if (not isinstance(body, dict) or set(body) != {"type", "value", "temporary"}
...@@ -442,6 +443,15 @@ def iam(request):...@@ -442,6 +443,15 @@ def iam(request):
442 profile = client.request("/admin/realms/master/users/" + user[1])443 profile = client.request("/admin/realms/master/users/" + user[1])
443 if profile["username"] == "admin":444 if profile["username"] == "admin":
444 raise Error(403, "The Keycloak administrator is managed outside the dashboard.")445 raise Error(403, "The Keycloak administrator is managed outside the dashboard.")
446 if user[2] == "/shale-username":
447 clients = client.request("/admin/realms/master/clients?clientId=shale")
448 clients = [item for item in clients if item["clientId"] == "shale"]
449 if len(clients) != 1:
450 raise Error(502, "Shale's sign-in client is unavailable.")
451 aliases = client.request(f"/admin/realms/master/users/{user[1]}/role-mappings/clients/{clients[0]['id']}/composite")
452 if len(aliases) > 1:
453 raise Error(502, "This account has multiple Shale usernames.")
454 return {"body": {"username": aliases[0]["name"] if aliases else profile["username"], "enabled": profile["enabled"]}}
445 if isinstance(body, dict) and "attributes" in body:455 if isinstance(body, dict) and "attributes" in body:
446 attributes = dict(profile.get("attributes", {}))456 attributes = dict(profile.get("attributes", {}))
447 picture = body["attributes"]["picture"]457 picture = body["attributes"]["picture"]