Post-Tyranny-Tech-Infrastru.../ansible/roles/zitadel/files/zitadel_api.py
Pieter 48ef4da920 Fix Zitadel deployment by removing FirstInstance variables
- Remove all ZITADEL_FIRSTINSTANCE_* environment variables
- Fixes migration error: duplicate key constraint violation
- Root cause: Bug in Zitadel v2.63.7 FirstInstance migration
- Workaround: Complete initial setup via web UI
- Upstream issue: https://github.com/zitadel/zitadel/issues/8791

Changes:
- Clean up obsolete documentation (OIDC_AUTOMATION.md, SETUP_GUIDE.md, COLLABORA_SETUP.md)
- Add PROJECT_REFERENCE.md for essential configuration info
- Add force recreate functionality with clean database volumes
- Update bootstrap instructions for web UI setup
- Document one-time manual setup requirement for OIDC automation

Zitadel now deploys successfully and is accessible at:
https://zitadel.test.vrije.cloud

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-06 16:43:57 +01:00

182 lines
5.6 KiB
Python

#!/usr/bin/env python3
"""
Zitadel API client using JWT authentication.
Fully automated OIDC app provisioning.
"""
import json
import sys
import time
import requests
import jwt
from typing import Dict, Optional
class ZitadelAPI:
"""Zitadel API client with JWT authentication."""
def __init__(self, domain: str, jwt_key_path: str):
"""Initialize with JWT key file."""
self.domain = domain
self.base_url = f"https://{domain}"
# Load JWT key
with open(jwt_key_path, 'r') as f:
self.jwt_key = json.load(f)
self.user_id = self.jwt_key.get("userId")
self.key_id = self.jwt_key.get("keyId")
self.private_key = self.jwt_key.get("key")
def get_access_token(self) -> str:
"""Get access token using JWT assertion."""
# Create JWT assertion
now = int(time.time())
payload = {
"iss": self.user_id,
"sub": self.user_id,
"aud": self.domain,
"iat": now,
"exp": now + 3600,
}
assertion = jwt.encode(
payload,
self.private_key,
algorithm="RS256",
headers={"kid": self.key_id}
)
# Exchange JWT for access token
token_url = f"{self.base_url}/oauth/v2/token"
data = {
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
"assertion": assertion,
"scope": "openid profile email urn:zitadel:iam:org:project:id:zitadel:aud",
}
response = requests.post(token_url, data=data)
if response.status_code == 200:
return response.json().get("access_token")
else:
raise Exception(f"Failed to get access token: {response.status_code} - {response.text}")
def create_project(self, access_token: str, name: str) -> Optional[str]:
"""Create a project."""
url = f"{self.base_url}/management/v1/projects"
headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
}
payload = {"name": name}
response = requests.post(url, headers=headers, json=payload)
if response.status_code in [200, 201]:
return response.json().get("id")
elif response.status_code == 409:
# Already exists, find it
return self.find_project(access_token, name)
else:
raise Exception(f"Failed to create project: {response.status_code} - {response.text}")
def find_project(self, access_token: str, name: str) -> Optional[str]:
"""Find existing project by name."""
url = f"{self.base_url}/management/v1/projects/_search"
headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
}
response = requests.post(url, headers=headers, json={})
if response.status_code == 200:
projects = response.json().get("result", [])
for project in projects:
if project.get("name") == name:
return project["id"]
return None
def create_oidc_app(
self,
access_token: str,
project_id: str,
app_name: str,
redirect_uri: str,
) -> Dict:
"""Create OIDC application."""
url = f"{self.base_url}/management/v1/projects/{project_id}/apps/oidc"
headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
}
payload = {
"name": app_name,
"redirectUris": [redirect_uri],
"responseTypes": ["OIDC_RESPONSE_TYPE_CODE"],
"grantTypes": [
"OIDC_GRANT_TYPE_AUTHORIZATION_CODE",
"OIDC_GRANT_TYPE_REFRESH_TOKEN",
],
"appType": "OIDC_APP_TYPE_WEB",
"authMethodType": "OIDC_AUTH_METHOD_TYPE_BASIC",
"postLogoutRedirectUris": [redirect_uri.rsplit("/", 1)[0] + "/"],
"version": "OIDC_VERSION_1_0",
"devMode": False,
"accessTokenType": "OIDC_TOKEN_TYPE_BEARER",
"accessTokenRoleAssertion": True,
"idTokenRoleAssertion": True,
"idTokenUserinfoAssertion": True,
"clockSkew": "0s",
}
response = requests.post(url, headers=headers, json=payload)
if response.status_code in [200, 201]:
return response.json()
else:
raise Exception(f"Failed to create OIDC app: {response.status_code} - {response.text}")
def main():
"""Main entry point."""
if len(sys.argv) < 5:
print("Usage: zitadel_api.py <domain> <jwt_key_path> <app_name> <redirect_uri>")
sys.exit(1)
domain = sys.argv[1]
jwt_key_path = sys.argv[2]
app_name = sys.argv[3]
redirect_uri = sys.argv[4]
try:
api = ZitadelAPI(domain, jwt_key_path)
# Get access token
access_token = api.get_access_token()
# Get or create project
project_id = api.create_project(access_token, "SSO Applications")
# Create OIDC app
result = api.create_oidc_app(access_token, project_id, app_name, redirect_uri)
# Output credentials
output = {
"status": "created",
"app_id": result.get("appId"),
"client_id": result.get("clientId"),
"client_secret": result.get("clientSecret"),
"redirect_uri": redirect_uri,
}
print(json.dumps(output))
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()