#!/usr/bin/env python3
"""
register_oauth_app.py
Automated OAuth app registration & Vault JWT wiring for homelab services.

This script automates the full workflow of registering a new service with
Authentik OIDC and wiring it for Vault JWT authentication.

Usage:
  python3 register_oauth_app.py \\
    --service-name my-service \\
    --namespace my-ns \\
    --redirect-uri "https://my-service.riotpiao.com/oauth2/callback" \\
    [--service-name-in-vault MY_SERVICE] \\
    [--vault-jwt-policy shell-secrets]

Requirements:
  - Authentik running in iam namespace (port-forward 7000:80 active)
  - Vault running in storage namespace
  - kubectl configured (KUBECONFIG → cluster-config/kubeconfig)
  - Environment variables set:
      AUTHENTIK_BOOTSTRAP_TOKEN       — Authentik API token
      {SERVICE_NAME}_OIDC_CLIENT_SECRET  — OAuth client secret (generated or from Vault)

Examples:

  # Simple: register a web app with default settings
  python3 register_oauth_app.py \\
    --service-name myapp \\
    --namespace apps \\
    --redirect-uri "https://myapp.riotpiao.com/callback"

  # Advanced: full service with Vault JWT auth
  python3 register_oauth_app.py \\
    --service-name myservice \\
    --namespace my-namespace \\
    --redirect-uri "https://myservice.riotpiao.com/oauth2/callback" \\
    --service-name-in-vault MYSERVICE \\
    --vault-jwt-policy shell-secrets \\
    --add-group myservice-admins \\
    --vault-jwt-bound-claims '{"groups":["myservice-admins"]}'
"""

import argparse
import json
import os
import subprocess
import sys
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from typing import Optional


BASE_AUTHENTIK_URL = "http://localhost:7000/api/v3"
VAULT_NAMESPACE = "storage"
IAM_NAMESPACE = "iam"


def _auth_headers() -> dict:
    """Return HTTP headers for Authentik API requests."""
    token = os.environ.get("AUTHENTIK_BOOTSTRAP_TOKEN", "")
    if not token:
        sys.exit(
            "ERROR: AUTHENTIK_BOOTSTRAP_TOKEN not set\n"
            "  Set it in ~/.authentik/.env or export it:\n"
            "    talos get cluster/AUTHENTIK_BOOTSTRAP_TOKEN --key AUTHENTIK_BOOTSTRAP_TOKEN"
        )
    return {
        "Authorization": f"Bearer {token}",
        "Content-Type": "application/json",
    }


def _request(method: str, path: str, body: Optional[dict] = None, **params) -> dict:
    """HTTP request helper for Authentik API."""
    url = BASE_AUTHENTIK_URL + path
    if params:
        url += "?" + urllib.parse.urlencode(params)
    
    req = urllib.request.Request(
        url,
        data=json.dumps(body).encode() if body else None,
        headers=_auth_headers(),
        method=method,
    )
    
    try:
        with urllib.request.urlopen(req) as resp:
            raw = resp.read()
            return json.loads(raw) if raw else {}
    except urllib.error.HTTPError as exc:
        detail = exc.read().decode(errors="replace")
        print(f"ERROR {method} {path}: HTTP {exc.code} — {detail}", file=sys.stderr)
        raise


def get_pk(path: str, **filters) -> Optional[str]:
    """Fetch the PK of the first result matching filters."""
    results = _request("GET", path, **filters).get("results", [])
    return results[0]["pk"] if results else None


def get_or_create(path: str, filters: dict, data: dict) -> str:
    """Create or fetch a resource; returns PK."""
    pk = get_pk(path, **filters)
    if pk is None:
        pk = _request("POST", path, data)["pk"]
        print(f"✓ created  {path.rstrip('/')} {filters}")
    else:
        print(f"✓ exists   {path.rstrip('/')} {filters}")
    return pk


def fetch_flows_and_signing_key() -> tuple[str, str, str]:
    """Fetch required Authentik resource PKs."""
    auth_pk = get_pk(
        "/flows/instances/",
        slug="default-provider-authorization-implicit-consent"
    )
    inval_pk = get_pk(
        "/flows/instances/",
        slug="default-provider-invalidation-flow"
    )
    
    if not auth_pk or not inval_pk:
        sys.exit(
            "ERROR: required Authentik flows not found\n"
            "  Run provision_oidc.py first to initialize defaults"
        )
    
    results = _request("GET", "/crypto/certificatekeypairs/", has_key=True).get("results", [])
    signing_pk = results[0]["pk"] if results else None
    
    if not signing_pk:
        sys.exit(
            "ERROR: no signing key found in Authentik\n"
            "  Create one: Authentik UI → System → Certificates"
        )
    
    return auth_pk, inval_pk, signing_pk


def apply_k8s_secret(name: str, namespace: str, literals: dict) -> None:
    """Create or update a Kubernetes secret."""
    env = {**os.environ}
    manifest = subprocess.run(
        [
            "kubectl", "create", "secret", "generic", name,
            "-n", namespace, "--dry-run=client", "-o", "yaml",
            *[f"--from-literal={k}={v}" for k, v in literals.items()],
        ],
        capture_output=True, text=True, check=True, env=env,
    ).stdout
    
    subprocess.run(
        ["kubectl", "apply", "-f", "-"],
        input=manifest, text=True, check=True, env=env
    )
    print(f"✓ secret   {name} → ns/{namespace}")


def create_authentik_provider(
    service_name: str,
    client_id: str,
    client_secret: str,
    redirect_uris: list[str],
    auth_flow_pk: str,
    inval_flow_pk: str,
    signing_key_pk: str,
) -> str:
    """Create OAuth2 provider in Authentik."""
    data = {
        "name": service_name,
        "client_id": client_id,
        "client_secret": client_secret,
        "authorization_flow": auth_flow_pk,
        "invalidation_flow": inval_flow_pk,
        "redirect_uris": [{"matching_mode": "strict", "url": u} for u in redirect_uris],
        "sub_mode": "hashed_user_id",
        "include_claims_in_id_token": True,
        "property_mappings": [],  # Will be populated by caller if needed
        "grant_types": ["authorization_code", "refresh_token"],
        "signing_key": signing_key_pk,
    }
    
    pk = get_pk("/providers/oauth2/", name=service_name)
    if pk is None:
        pk = _request("POST", "/providers/oauth2/", data)["pk"]
        print(f"✓ created  provider={service_name}  client_id={client_id}")
    else:
        _request("PATCH", f"/providers/oauth2/{pk}/", data)
        print(f"✓ synced   provider={service_name}  (credentials from Vault)")
    
    return pk


def create_authentik_application(
    display_name: str,
    slug: str,
    provider_pk: str,
    launch_url: Optional[str] = None,
) -> str:
    """Create application in Authentik."""
    data = {"name": display_name, "slug": slug, "provider": provider_pk}
    if launch_url:
        data["meta_launch_url"] = launch_url
    
    return get_or_create(
        "/core/applications/",
        filters={"slug": slug},
        data=data
    )


def create_authentik_group(group_name: str) -> str:
    """Create a group in Authentik."""
    return get_or_create(
        "/core/groups/",
        filters={"name": group_name},
        data={"name": group_name, "is_superuser": False}
    )


def bind_group_to_app(app_pk: str, group_pk: str) -> None:
    """Bind a group to an application (allow-list policy)."""
    existing = _request(
        "GET", "/policies/bindings/",
        target=app_pk, group=group_pk
    ).get("results", [])
    
    if existing:
        print(f"✓ exists   binding group={group_pk} → app={app_pk}")
    else:
        _request("POST", "/policies/bindings/", {
            "target": app_pk,
            "group": group_pk,
            "enabled": True,
            "order": 0,
        })
        print(f"✓ created  binding group={group_pk} → app={app_pk}")


def create_vault_jwt_policy(
    service_name: str,
    vault_addr: str,
) -> str:
    """Create a Vault policy for the service."""
    policy_name = f"service-read-{service_name}"
    
    # Policy definition: service can read its own secrets
    policy_rules = f"""
path "secret/data/services/{service_name}/*" {{
  capabilities = ["read"]
}}
path "secret/data/cluster/*" {{
  capabilities = ["read"]
}}
"""
    
    # Write policy via vault CLI (requires auth)
    try:
        subprocess.run(
            ["vault", "policy", "write", policy_name, "-"],
            input=policy_rules,
            text=True,
            check=True,
            env={**os.environ, "VAULT_ADDR": vault_addr}
        )
        print(f"✓ created  Vault policy={policy_name}")
    except subprocess.CalledProcessError as e:
        print(f"⚠ warning  Failed to create Vault policy: {e}", file=sys.stderr)
        return ""
    
    return policy_name


def create_vault_jwt_role(
    service_name: str,
    vault_addr: str,
    policies: list[str],
    bound_claims: Optional[dict] = None,
) -> None:
    """Create a Vault JWT auth role for the service."""
    bound_claims_json = json.dumps(bound_claims) if bound_claims else "{}"
    
    role_config = {
        "role_type": "jwt",
        "bound_audiences": ["vault"],
        "user_claim": "sub",
        "bound_claims": bound_claims,
        "token_policies": policies,
        "token_ttl": "4h",
        "token_max_ttl": "8h",
    }
    
    try:
        subprocess.run(
            ["vault", "write", f"auth/jwt/role/{service_name}", "-"],
            input=json.dumps(role_config),
            text=True,
            check=True,
            env={**os.environ, "VAULT_ADDR": vault_addr}
        )
        print(f"✓ created  Vault JWT role={service_name}  policies={policies}")
    except subprocess.CalledProcessError as e:
        print(f"⚠ warning  Failed to create Vault JWT role: {e}", file=sys.stderr)


def main() -> None:
    parser = argparse.ArgumentParser(
        description="Register a new OAuth service with Authentik + Vault",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=__doc__,
    )
    
    parser.add_argument(
        "--service-name",
        required=True,
        help="Service name (used as Authentik provider/app slug)"
    )
    parser.add_argument(
        "--namespace",
        required=True,
        help="Kubernetes namespace where service runs"
    )
    parser.add_argument(
        "--redirect-uri",
        required=True,
        help="OAuth2 redirect URI (e.g., https://myapp.riotpiao.com/callback)"
    )
    parser.add_argument(
        "--service-name-in-vault",
        default=None,
        help="Service name for Vault (defaults to uppercase service-name)"
    )
    parser.add_argument(
        "--vault-jwt-policy",
        default="shell-secrets",
        help="Vault policy to attach to JWT role (comma-separated for multiple)"
    )
    parser.add_argument(
        "--add-group",
        default=None,
        help="Create and bind a group to the app (e.g., 'myservice-admins')"
    )
    parser.add_argument(
        "--vault-jwt-bound-claims",
        default=None,
        help='JSON string of claims to bind JWT role (e.g., \'{"groups":["myservice-admins"]}\')'
    )
    parser.add_argument(
        "--no-vault",
        action="store_true",
        help="Skip Vault JWT role creation (just register with Authentik)"
    )
    parser.add_argument(
        "--vault-addr",
        default=None,
        help="Vault address (defaults to localhost:8200 via port-forward)"
    )
    
    args = parser.parse_args()
    
    service_name = args.service_name.lower()
    namespace = args.namespace
    redirect_uri = args.redirect_uri
    vault_service_name = (args.service_name_in_vault or service_name).upper()
    vault_addr = args.vault_addr or "http://127.0.0.1:8200"
    vault_policies = [p.strip() for p in args.vault_jwt_policy.split(",")]
    
    # Fetch or generate client secret from environment / Vault
    client_secret_env = f"{vault_service_name}_OIDC_CLIENT_SECRET"
    client_secret = os.environ.get(client_secret_env, "")
    
    if not client_secret:
        sys.exit(
            f"ERROR: {client_secret_env} not set\n"
            f"  Generate and store in Vault:\n"
            f"    talos put cluster/{client_secret_env} "
            f"{client_secret_env}=$(openssl rand -hex 32)"
        )
    
    print(f"\n{'='*70}")
    print(f"OAuth App Registration: {service_name}")
    print(f"{'='*70}\n")
    
    print(f"📋 Configuration:")
    print(f"  Service Name:     {service_name}")
    print(f"  Namespace:        {namespace}")
    print(f"  Redirect URI:     {redirect_uri}")
    print(f"  Vault Service:    {vault_service_name}")
    print(f"  Vault Policies:   {', '.join(vault_policies)}")
    if args.add_group:
        print(f"  Group:            {args.add_group}")
    print()
    
    # Fetch Authentik resources
    print("🔍 Fetching Authentik configuration...")
    auth_flow_pk, inval_flow_pk, signing_key_pk = fetch_flows_and_signing_key()
    print(f"✓ flows & signing key found\n")
    
    # Create Authentik provider
    print("🔐 Creating Authentik OAuth2 Provider...")
    provider_pk = create_authentik_provider(
        service_name=service_name,
        client_id=service_name,
        client_secret=client_secret,
        redirect_uris=[redirect_uri],
        auth_flow_pk=auth_flow_pk,
        inval_flow_pk=inval_flow_pk,
        signing_key_pk=signing_key_pk,
    )
    print()
    
    # Create Authentik application
    print("📱 Creating Authentik Application...")
    app_pk = create_authentik_application(
        display_name=service_name.replace("-", " ").title(),
        slug=service_name,
        provider_pk=provider_pk,
        launch_url=f"https://{service_name}.riotpiao.com"
    )
    print()
    
    # Create and bind group (if requested)
    if args.add_group:
        print(f"👥 Creating Group: {args.add_group}")
        group_pk = create_authentik_group(args.add_group)
        bind_group_to_app(app_pk, group_pk)
        print()
    
    # Create K8s secret
    print("🔑 Creating Kubernetes Secret...")
    apply_k8s_secret(
        f"{service_name}-oidc",
        namespace,
        {
            "client_id": service_name,
            "client_secret": client_secret,
            "issuer_url": f"http://authentik-server.iam.svc.cluster.local/application/o/{service_name}/",
            "redirect_uri": redirect_uri,
        }
    )
    print()
    
    # Create Vault resources (if not disabled)
    if not args.no_vault:
        print("🔐 Configuring Vault JWT Authentication...")
        
        # Parse bound claims if provided
        bound_claims = None
        if args.vault_jwt_bound_claims:
            try:
                bound_claims = json.loads(args.vault_jwt_bound_claims)
            except json.JSONDecodeError as e:
                print(f"⚠ warning  Invalid JSON for --vault-jwt-bound-claims: {e}", file=sys.stderr)
        
        # Create Vault policy
        policy_name = create_vault_jwt_policy(service_name, vault_addr)
        
        # Create Vault JWT role
        if policy_name:
            all_policies = list(vault_policies) + [policy_name]
            create_vault_jwt_role(
                service_name,
                vault_addr,
                all_policies,
                bound_claims
            )
        print()
    
    # Summary
    print(f"{'='*70}")
    print(f"✅ Registration Complete!")
    print(f"{'='*70}\n")
    
    print("📝 Next Steps:\n")
    
    print(f"1. Configure your service with these environment variables:")
    print(f"   export OIDC_ISSUER_URL='http://authentik-server.iam.svc.cluster.local/application/o/{service_name}/'")
    print(f"   export OIDC_CLIENT_ID='{service_name}'")
    print(f"   export OIDC_CLIENT_SECRET='$({vault_service_name}_OIDC_CLIENT_SECRET)'")
    print(f"   export OIDC_REDIRECT_URI='{redirect_uri}'")
    print()
    
    print(f"2. Mount the Kubernetes secret in your Helm values:")
    print(f"   env:")
    print(f"     - name: OIDC_CLIENT_SECRET")
    print(f"       valueFrom:")
    print(f"         secretKeyRef:")
    print(f"           name: {service_name}-oidc")
    print(f"           key: client_secret")
    print()
    
    if not args.no_vault:
        print(f"3. Test Vault JWT authentication:")
        print(f"   kubectl port-forward -n {VAULT_NAMESPACE} svc/vault 8200:8200 &")
        print(f"   vault login -method=oidc role=homelab")
        print(f"   vault read auth/jwt/role/{service_name}")
        print()
    
    print(f"4. Verify OAuth flow:")
    print(f"   kubectl port-forward -n {IAM_NAMESPACE} svc/authentik-server 7000:80 &")
    print(f"   # Open Authentik UI: http://localhost:7000/if/admin/")
    print(f"   # Check: Applications → {service_name}")
    print()


if __name__ == "__main__":
    main()
