Post-Tyranny-Tech-Infrastru.../ansible/roles/zitadel/files/create_oidc_app.py
Pieter 8866411ef3 Implement fully automated OIDC/SSO provisioning (#4)
This commit eliminates all manual configuration steps for OIDC/SSO setup,
making the infrastructure fully scalable to dozens or hundreds of servers.

## Automation Overview

The deployment now automatically:
1. Authenticates with Zitadel using admin credentials
2. Creates OIDC application via Zitadel Management API
3. Retrieves client ID and secret
4. Configures Nextcloud OIDC provider

**Zero manual steps required!**

## New Components

### Zitadel OIDC Automation
- `files/get_admin_token.sh`: OAuth2 authentication script
- `files/create_oidc_app.py`: Python script for OIDC app creation via API
- `tasks/oidc-apps.yml`: Ansible orchestration for full automation

### API Integration
- Uses Zitadel Management API v1
- Resource Owner Password Credentials flow for admin auth
- Creates OIDC apps with proper security settings:
  - Authorization Code + Refresh Token grants
  - JWT access tokens
  - Role and UserInfo assertions enabled
  - Proper redirect URI configuration

### Nextcloud Integration
- Updated `tasks/oidc.yml` to auto-configure provider
- Receives credentials from Zitadel automation
- Configures discovery URI automatically
- Handles idempotency (skips if already configured)

## Scalability Benefits

### Before (Manual)
```
1. Deploy infrastructure
2. Login to Zitadel console
3. Create OIDC app manually
4. Copy client ID/secret
5. SSH to server
6. Run occ command with credentials
```

**Time per server: ~10-15 minutes**

### After (Automated)
```
1. Deploy infrastructure
```

**Time per server: ~0 minutes (fully automated)**

### Impact
- 10 servers: Save ~2 hours of manual work
- 50 servers: Save ~10 hours of manual work
- 100 servers: Save ~20 hours of manual work

## Security

- Admin credentials encrypted with SOPS
- Access tokens are ephemeral (generated per deployment)
- Client secrets never logged (`no_log: true`)
- All API calls over HTTPS only
- Credentials passed via Ansible facts (memory only)

## Documentation

Added comprehensive documentation:
- `docs/OIDC_AUTOMATION.md`: Full automation guide
- How it works
- Technical implementation details
- Troubleshooting guide
- Security considerations

## Testing

The automation is idempotent and handles:
-  First-time setup (creates app)
-  Subsequent runs (skips if exists)
-  Error handling (fails gracefully)
-  Credential validation

## Next Steps

Users can immediately login via SSO after deployment:
1. Visit https://nextcloud.{client}.vrije.cloud
2. Click "Login with Zitadel"
3. Enter Zitadel credentials
4. Automatically logged into Nextcloud

Closes #4

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

174 lines
5.3 KiB
Python

#!/usr/bin/env python3
"""
Create OIDC application in Zitadel using the Management API.
This script automates the creation of OIDC applications for services like Nextcloud.
It uses Zitadel's REST API with service account authentication.
"""
import json
import sys
import requests
import time
from typing import Dict, Optional
class ZitadelOIDCManager:
"""Manage OIDC applications in Zitadel."""
def __init__(self, domain: str, pat_token: str):
"""Initialize the OIDC manager.
Args:
domain: Zitadel domain (e.g., zitadel.example.com)
pat_token: Personal Access Token for authentication
"""
self.domain = domain
self.base_url = f"https://{domain}"
self.headers = {
"Authorization": f"Bearer {pat_token}",
"Content-Type": "application/json",
}
def get_default_project(self) -> Optional[str]:
"""Get the default project ID."""
url = f"{self.base_url}/management/v1/projects/_search"
response = requests.post(url, headers=self.headers, json={})
if response.status_code == 200:
projects = response.json().get("result", [])
if projects:
return projects[0]["id"]
return None
def check_app_exists(self, project_id: str, app_name: str) -> Optional[Dict]:
"""Check if an OIDC app already exists.
Args:
project_id: Project ID
app_name: Application name
Returns:
App data if exists, None otherwise
"""
url = f"{self.base_url}/management/v1/projects/{project_id}/apps/_search"
response = requests.post(url, headers=self.headers, json={})
if response.status_code == 200:
apps = response.json().get("result", [])
for app in apps:
if app.get("name") == app_name:
return app
return None
def create_oidc_app(
self,
project_id: str,
app_name: str,
redirect_uris: list,
post_logout_redirect_uris: list = None,
) -> Dict:
"""Create an OIDC application.
Args:
project_id: Project ID
app_name: Application name
redirect_uris: List of redirect URIs
post_logout_redirect_uris: List of post-logout redirect URIs
Returns:
Created app data including client ID and secret
"""
url = f"{self.base_url}/management/v1/projects/{project_id}/apps/oidc"
payload = {
"name": app_name,
"redirectUris": redirect_uris,
"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": post_logout_redirect_uris or redirect_uris,
"version": "OIDC_VERSION_1_0",
"devMode": False,
"accessTokenType": "OIDC_TOKEN_TYPE_JWT",
"accessTokenRoleAssertion": True,
"idTokenRoleAssertion": True,
"idTokenUserinfoAssertion": True,
"clockSkew": "0s",
"skipNativeAppSuccessPage": False,
}
response = requests.post(url, headers=self.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: create_oidc_app.py <domain> <pat_token> <app_name> <redirect_uri>")
sys.exit(1)
domain = sys.argv[1]
pat_token = sys.argv[2]
app_name = sys.argv[3]
redirect_uri = sys.argv[4]
try:
manager = ZitadelOIDCManager(domain, pat_token)
# Get default project
project_id = manager.get_default_project()
if not project_id:
print("Error: No project found", file=sys.stderr)
sys.exit(1)
# Check if app already exists
existing_app = manager.check_app_exists(project_id, app_name)
if existing_app:
print(
json.dumps(
{
"status": "exists",
"app_id": existing_app.get("id"),
"message": f"App '{app_name}' already exists",
}
)
)
sys.exit(0)
# Create new app
result = manager.create_oidc_app(
project_id=project_id,
app_name=app_name,
redirect_uris=[redirect_uri],
post_logout_redirect_uris=[redirect_uri.rsplit("/", 1)[0] + "/"],
)
# Extract client 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(json.dumps({"status": "error", "message": str(e)}), file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()