1#!/usr/bin/env python3
2import json
3import sys
4import urllib.parse
5
6from api import Keycloak
7
8
9data = json.load(sys.stdin)
10request = data["request"]
11if request["kind"] != "client":
12 raise ValueError("unsupported Keycloak input")
13stage_id = data.get("stageId")
14client_id = request["clientId"]
15if stage_id and client_id != stage_id:
16 client_id += "-" + stage_id[-8:]
17if data.get("operation") == "delete" and not stage_id:
18 raise ValueError("refusing to delete a production client")
19existing = data.get("existing") or {}
20if existing.get("clientId") and existing["clientId"] != client_id:
21 raise ValueError("client ID changed; migrate the existing client before deployment")
22keycloak = Keycloak(data["host"], data["providerSecrets"]["password"])
23desired = {
24 "protocol": "openid-connect",
25 "clientId": client_id,
26 "name": request["name"],
27 "publicClient": False,
28 "authorizationServicesEnabled": False,
29 "serviceAccountsEnabled": False,
30 "implicitFlowEnabled": False,
31 "directAccessGrantsEnabled": False,
32 "standardFlowEnabled": True,
33 "frontchannelLogout": True,
34 "redirectUris": request["redirectUris"],
35 "attributes": {"post.logout.redirect.uris": "*"},
36}
37path = "/admin/realms/master/clients"
38query = "?" + urllib.parse.urlencode({"clientId": client_id})
39found = keycloak.request(path + query)
40matches = [client for client in found if client["clientId"] == client_id]
41if len(matches) > 1:
42 raise ValueError(f"duplicate Keycloak client: {client_id}")
43if data.get("operation") == "delete":
44 if matches:
45 keycloak.request(f"{path}/{matches[0]['id']}", "DELETE")
46 sys.exit(0)
47if not matches:
48 keycloak.request(path, "POST", desired)
49 found = keycloak.request(path + query)
50 matches = [client for client in found if client["clientId"] == client_id]
51 if len(matches) != 1:
52 raise ValueError(f"Keycloak client was not created: {client_id}")
53uuid = matches[0]["id"]
54current = keycloak.request(f"{path}/{uuid}")
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:
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"]})
97secret = keycloak.request(f"{path}/{uuid}/client-secret")["value"]
98if not secret:
99 raise ValueError(f"Keycloak client has no secret: {client_id}")
100print(json.dumps({"clientId": client_id, "clientSecret": secret, "issuerUrl": f"https://{data['host']}/realms/master"}))