| 1 | #!/usr/bin/env python3 |
| 2 | import json |
| 3 | import sys |
| 4 | import urllib.parse |
| 5 | |
| 6 | from api import Keycloak |
| 7 | |
| 8 | |
| 9 | data = json.load(sys.stdin) |
| 10 | request = data["request"] |
| 11 | if request["kind"] != "client": |
| 12 | raise ValueError("unsupported Keycloak input") |
| 13 | stage_id = data.get("stageId") |
| 14 | client_id = request["clientId"] |
| 15 | if stage_id and client_id != stage_id: |
| 16 | client_id += "-" + stage_id[-8:] |
| 17 | if data.get("operation") == "delete" and not stage_id: |
| 18 | raise ValueError("refusing to delete a production client") |
| 19 | existing = data.get("existing") or {} |
| 20 | if existing.get("clientId") and existing["clientId"] != client_id: |
| 21 | raise ValueError("client ID changed; migrate the existing client before deployment") |
| 22 | keycloak = Keycloak(data["host"], data["providerSecrets"]["password"]) |
| 23 | desired = { |
| 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 | } |
| 37 | path = "/admin/realms/master/clients" |
| 38 | query = "?" + urllib.parse.urlencode({"clientId": client_id}) |
| 39 | found = keycloak.request(path + query) |
| 40 | matches = [client for client in found if client["clientId"] == client_id] |
| 41 | if len(matches) > 1: |
| 42 | raise ValueError(f"duplicate Keycloak client: {client_id}") |
| 43 | if data.get("operation") == "delete": |
| 44 | if matches: |
| 45 | keycloak.request(f"{path}/{matches[0]['id']}", "DELETE") |
| 46 | sys.exit(0) |
| 47 | if 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}") |
| 53 | uuid = matches[0]["id"] |
| 54 | current = keycloak.request(f"{path}/{uuid}") |
| 55 | attributes = {**(current.get("attributes") or {}), **desired["attributes"]} |
| 56 | if 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}) |
| 58 | aliases = request.get("usernameAliases", {}) |
| 59 | if 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"]}) |
| 97 | secret = keycloak.request(f"{path}/{uuid}/client-secret")["value"] |
| 98 | if not secret: |
| 99 | raise ValueError(f"Keycloak client has no secret: {client_id}") |
| 100 | print(json.dumps({"clientId": client_id, "clientSecret": secret, "issuerUrl": f"https://{data['host']}/realms/master"})) |