#!/usr/bin/env bash
# Copyright (c) 2026 Asteria Labs. All rights reserved.
# Create and verify the Microsoft Entra application used by Asteria Cloud.
#
# This file is deliberately standalone. It needs only Bash, Python 3, curl,
# OpenSSL (for optional group access), and the authenticated Azure CLI already
# available in Azure Cloud Shell.
#
# Guided production setup:
#   bash setup-microsoft-connector.sh
#
# Repeatable setup:
#   bash setup-microsoft-connector.sh --services files,mail --yes
#
# Options:
#   --redirect-uri URL     callback URL (default: Asteria Cloud production)
#   --services a,b         service keys (default: all available services)
#   --name NAME            display name (default: Asteria Cloud connector)
#   --app-id ID            update an existing registration
#   --with-group-access    add the certificate and application permissions
#   --cert-days N          certificate lifetime (default: 730)
#   --out-dir DIR          certificate output directory (default: .)
#   --no-admin-consent     leave tenant-wide consent to another administrator
#   --verify-only          check an existing registration without changing it
#   --list-services        list the available service keys
#   --yes                  apply the displayed plan without prompting

set -euo pipefail

GRAPH_API_ID="00000003-0000-0000-c000-000000000000"
SHAREPOINT_API_ID="00000003-0000-0ff1-ce00-000000000000"

NAME="Asteria Cloud connector"
SERVICES=""
REDIRECT_URI="https://app.asteria-labs.com/api/connectors/callback"
APP_ID=""
GROUP_ACCESS=0
CERT_DAYS=730
OUT_DIR="."
ADMIN_CONSENT=1
VERIFY_ONLY=0
ASSUME_YES=0
INTERACTIVE_SETUP=0

die() { echo "error: $*" >&2; exit 1; }
say() { echo "==> $*"; }
need() { if [[ $# -lt 2 || -z "$2" ]]; then die "$1 requires a value"; fi; }

permission_tool() {
  python3 - "$@" <<'PY'
"""Resolve and verify the permissions used by the Asteria Cloud Entra setup.

The permission catalog is bundled into the script so it runs as one file in
Azure Cloud Shell. Asteria Cloud's CI checks that this copy remains identical to
the catalog used by the product.

**Names are resolved to GUIDs against the tenant's own service principals.**
Entra's `requiredResourceAccess` carries GUIDs, and the pair that matters here
is the two permissions named `Sites.Read.All`: Microsoft Graph publishes one and
SharePoint publishes another, they are different permissions, and granting
Graph's leaves site-group membership quietly unresolvable. Resolving each name
against the API it is asked for makes that mix-up unrepresentable rather than
documented.

Both now appear in one plan and they still do not meet. The `sites` service asks
for **Graph's**, delegated, so a person can browse to a SharePoint folder; group
access asks for **SharePoint's**, as an application role spent through the
certificate. Selecting one never grants the other.

`verify` closes the loop by reading the registration back: a permission that did
not land, and a permission nobody asked for, are both reported.
"""

from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path
from typing import Any

# Microsoft's own first-party application ids, stable across every tenant.
GRAPH_API_ID = "00000003-0000-0000-c000-000000000000"
SHAREPOINT_API_ID = "00000003-0000-0ff1-ce00-000000000000"

API_NAMES = {GRAPH_API_ID: "Microsoft Graph", SHAREPOINT_API_ID: "SharePoint"}

# Requested on every consent and not part of any service: `offline_access` is
# what yields a refresh token, and without it a credential dies at its first
# access-token expiry. `User.Read` is the least-privileged Graph permission for
# the `/me` lookup used to attach captured file ACLs to the connected person.
OFFLINE_ACCESS = "offline_access"

# Asteria Cloud's CI ties this customer-facing catalog to the runtime catalog,
# so changing either copy alone fails instead of silently changing authority.
BASE_SCOPES: dict[str, tuple[str, ...]] = {
    "microsoft": ("openid", "email", "profile", "User.Read"),
}
SERVICE_SCOPES: dict[str, dict[str, tuple[str, ...]]] = {
    "microsoft": {
        "files": ("Files.Read.All",),
        "mail": ("Mail.Read",),
        "mail_write": ("Mail.ReadWrite", "Mail.Send"),
        "calendar": ("Calendars.ReadWrite",),
        "files_write": ("Files.ReadWrite.All",),
        "sites": ("Sites.Read.All",),
        "directory": ("User.Read.All", "GroupMember.Read.All"),
        "teams": (
            "Chat.Read",
            "Team.ReadBasic.All",
            "Channel.ReadBasic.All",
            "ChannelMessage.Read.All",
        ),
        "teams_write": ("ChatMessage.Send", "ChannelMessage.Send"),
    }
}
RESTRICTED_SERVICES: dict[str, frozenset[str]] = {
    "microsoft": frozenset({"directory", "teams"}),
}
SERVICE_LABELS = {
    "files": "Files (read)",
    "files_write": "Files (create and edit)",
    "sites": "SharePoint sites (browse)",
    "mail": "Mail (read)",
    "mail_write": "Mail (draft and send)",
    "calendar": "Calendar",
    "directory": "Directory",
    "teams": "Teams (read)",
    "teams_write": "Teams (send)",
}

# The app-only half of group access has no delegated catalog entry because no
# user consents to it. These application permissions use the certificate.
GROUP_ACCESS_PERMISSIONS: dict[str, tuple[str, ...]] = {
    GRAPH_API_ID: ("Files.Read.All", "User.ReadBasic.All", "GroupMember.Read.All"),
    SHAREPOINT_API_ID: ("Sites.Read.All",),
}


class PlanError(Exception):
    """A request the script must refuse rather than approximate."""


def known_services(provider: str) -> tuple[str, ...]:
    return tuple(SERVICE_SCOPES.get(provider, {}))


def build_plan(provider: str, services: list[str], *, group_access: bool) -> dict[str, Any]:
    """Name every permission the chosen services need, and nothing else."""
    catalog = SERVICE_SCOPES.get(provider)
    if catalog is None:
        raise PlanError(f"unknown provider {provider!r}")

    unknown = [service for service in services if service not in catalog]
    if unknown:
        raise PlanError(
            f"unknown service(s) {', '.join(sorted(unknown))} (known: {', '.join(sorted(catalog))})"
        )

    delegated = list(BASE_SCOPES[provider]) + [OFFLINE_ACCESS]
    for service in services:
        for scope in catalog[service]:
            if scope not in delegated:
                delegated.append(scope)

    restricted = RESTRICTED_SERVICES.get(provider, frozenset())
    return {
        "provider": provider,
        "services": list(services),
        "delegated": delegated,
        "admin_consent_services": [s for s in services if s in restricted],
        "application": (
            {api: list(names) for api, names in GROUP_ACCESS_PERMISSIONS.items()}
            if group_access
            else {}
        ),
    }


def _index(principal: dict[str, Any]) -> tuple[dict[str, dict], dict[str, dict]]:
    delegated = {s["value"]: s for s in principal.get("oauth2PermissionScopes") or []}
    application = {r["value"]: r for r in principal.get("appRoles") or []}
    return delegated, application


def resolve(
    plan: dict[str, Any],
    principals: dict[str, dict[str, Any]],
    existing: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
    """Turn permission names into the `requiredResourceAccess` Entra takes.

    `existing` is a registration's current access, merged in so that adding a
    service later does not silently drop the services already there.
    """
    wanted: dict[str, dict[str, str]] = {}
    admin_consent: list[str] = []
    consent_resources: dict[str, dict[str, Any]] = {}

    def take(api: str, names: list[str], kind: str) -> None:
        principal = principals.get(api)
        if principal is None:
            raise PlanError(
                f"no service principal for {API_NAMES.get(api, api)} ({api}) in this tenant"
            )
        delegated_scopes, application_roles = _index(principal)
        table = delegated_scopes if kind == "Scope" else application_roles
        resource_id = principal.get("id")
        if not resource_id:
            raise PlanError(f"{API_NAMES.get(api, api)} service principal has no object id")
        consent = consent_resources.setdefault(
            api,
            {
                "resourceAppId": api,
                "resourceId": resource_id,
                "delegatedScopes": [],
                "appRoleIds": [],
            },
        )
        for name in names:
            entry = table.get(name)
            if entry is None:
                raise PlanError(
                    f"{API_NAMES.get(api, api)} publishes no {kind} permission named {name!r}"
                )
            wanted.setdefault(api, {})[entry["id"]] = kind
            target = consent["delegatedScopes"] if kind == "Scope" else consent["appRoleIds"]
            value = name if kind == "Scope" else entry["id"]
            if value not in target:
                target.append(value)
            # Application permissions always need a tenant administrator;
            # delegated ones say so themselves.
            if kind == "Role" or entry.get("type") == "Admin":
                admin_consent.append(f"{API_NAMES.get(api, api)}/{name} ({kind})")

    take(GRAPH_API_ID, plan["delegated"], "Scope")
    for api, names in plan.get("application", {}).items():
        take(api, names, "Role")

    added = {api: dict(ids) for api, ids in wanted.items()}
    for resource in existing or []:
        api = resource["resourceAppId"]
        for access in resource.get("resourceAccess") or []:
            if access["id"] in wanted.get(api, {}):
                added.get(api, {}).pop(access["id"], None)
            else:
                wanted.setdefault(api, {})[access["id"]] = access["type"]

    return {
        "requiredResourceAccess": [
            {
                "resourceAppId": api,
                "resourceAccess": [{"id": pid, "type": kind} for pid, kind in sorted(ids.items())],
            }
            for api, ids in sorted(wanted.items())
        ],
        "adminConsent": sorted(set(admin_consent)),
        "consentResources": list(consent_resources.values()),
        "added": sum(len(ids) for ids in added.values()),
    }


def build_admin_consent_requests(
    resolved: dict[str, Any],
    client_service_principal_id: str,
    delegated_grants: dict[str, Any],
    app_role_assignments: dict[str, Any],
) -> list[dict[str, Any]]:
    """Build idempotent Microsoft Graph writes for tenant-wide consent."""
    grants = delegated_grants.get("value") or []
    assignments = {
        (assignment.get("resourceId"), assignment.get("appRoleId"))
        for assignment in app_role_assignments.get("value") or []
    }
    requests: list[dict[str, Any]] = []

    for resource in resolved.get("consentResources") or []:
        resource_id = resource["resourceId"]
        scopes = resource.get("delegatedScopes") or []
        if scopes:
            matching = [
                grant
                for grant in grants
                if grant.get("clientId") == client_service_principal_id
                and grant.get("resourceId") == resource_id
                and grant.get("consentType") == "AllPrincipals"
            ]
            if len(matching) > 1:
                raise PlanError(
                    f"multiple tenant-wide delegated grants exist for {resource['resourceAppId']}"
                )
            if matching:
                grant = matching[0]
                existing_scopes = set((grant.get("scope") or "").split())
                combined = sorted(existing_scopes | set(scopes))
                if set(combined) != existing_scopes:
                    requests.append(
                        {
                            "method": "patch",
                            "url": f"https://graph.microsoft.com/v1.0/oauth2PermissionGrants/{grant['id']}",
                            "body": {"scope": " ".join(combined)},
                        }
                    )
            else:
                requests.append(
                    {
                        "method": "post",
                        "url": "https://graph.microsoft.com/v1.0/oauth2PermissionGrants",
                        "body": {
                            "clientId": client_service_principal_id,
                            "consentType": "AllPrincipals",
                            "resourceId": resource_id,
                            "scope": " ".join(sorted(set(scopes))),
                        },
                    }
                )

        for app_role_id in resource.get("appRoleIds") or []:
            if (resource_id, app_role_id) in assignments:
                continue
            requests.append(
                {
                    "method": "post",
                    "url": (
                        f"https://graph.microsoft.com/v1.0/servicePrincipals/{resource_id}"
                        "/appRoleAssignedTo"
                    ),
                    "body": {
                        "principalId": client_service_principal_id,
                        "resourceId": resource_id,
                        "appRoleId": app_role_id,
                    },
                }
            )

    return requests


def verify(expected: dict[str, Any], application: dict[str, Any]) -> dict[str, Any]:
    """Compare a registration against the plan, in both directions."""
    want = {
        resource["resourceAppId"]: {a["id"] for a in resource["resourceAccess"]}
        for resource in expected["requiredResourceAccess"]
    }
    have = {
        resource["resourceAppId"]: {a["id"] for a in resource.get("resourceAccess") or []}
        for resource in application.get("requiredResourceAccess") or []
    }
    missing = {api: sorted(ids - have.get(api, set())) for api, ids in want.items()}
    extra = {api: sorted(ids - want.get(api, set())) for api, ids in have.items()}
    return {
        "missing": {api: ids for api, ids in missing.items() if ids},
        "extra": {api: ids for api, ids in extra.items() if ids},
    }


def _read(path: str) -> Any:
    return json.loads(Path(path).read_text())


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    sub = parser.add_subparsers(dest="command", required=True)

    p_plan = sub.add_parser("plan", help="name the permissions the chosen services need")
    p_plan.add_argument("--provider", default="microsoft")
    p_plan.add_argument("--services", default="")
    p_plan.add_argument("--group-access", action="store_true")

    p_list = sub.add_parser("services", help="list the provider's service keys")
    p_list.add_argument("--provider", default="microsoft")
    p_list.add_argument("--keys-only", action="store_true")

    p_catalog = sub.add_parser("catalog", help="print the service-to-permission catalog as JSON")
    p_catalog.add_argument("--provider", default="microsoft")

    p_resolve = sub.add_parser("resolve", help="turn permission names into Entra GUIDs")
    p_resolve.add_argument("--plan", required=True)
    p_resolve.add_argument("--graph-principal", required=True)
    p_resolve.add_argument("--sharepoint-principal")
    p_resolve.add_argument("--existing", help="the registration's current requiredResourceAccess")

    p_verify = sub.add_parser("verify", help="check a registration against the resolved plan")
    p_verify.add_argument("--expected", required=True)
    p_verify.add_argument("--application", required=True)

    p_consent = sub.add_parser("consent", help="build idempotent Microsoft Graph consent writes")
    p_consent.add_argument("--resolved", required=True)
    p_consent.add_argument("--client-service-principal-id", required=True)
    p_consent.add_argument("--delegated-grants", required=True)
    p_consent.add_argument("--app-role-assignments", required=True)

    args = parser.parse_args(argv)

    try:
        if args.command == "services":
            if args.keys_only:
                print(",".join(known_services(args.provider)))
                return 0
            for key in known_services(args.provider):
                print(f"  {key:<12} {SERVICE_LABELS.get(key, key)}")
            return 0
        if args.command == "catalog":
            catalog = SERVICE_SCOPES.get(args.provider)
            if catalog is None:
                raise PlanError(f"unknown provider {args.provider!r}")
            print(json.dumps({key: list(value) for key, value in catalog.items()}))
            return 0
        if args.command == "plan":
            services = [service.strip() for service in args.services.split(",") if service.strip()]
            plan = build_plan(args.provider, services, group_access=args.group_access)
            print(json.dumps(plan, indent=2))
            return 0
        if args.command == "resolve":
            principals = {GRAPH_API_ID: _read(args.graph_principal)}
            if args.sharepoint_principal:
                principals[SHAREPOINT_API_ID] = _read(args.sharepoint_principal)
            existing = _read(args.existing) if args.existing else None
            print(json.dumps(resolve(_read(args.plan), principals, existing), indent=2))
            return 0
        if args.command == "consent":
            requests = build_admin_consent_requests(
                _read(args.resolved),
                args.client_service_principal_id,
                _read(args.delegated_grants),
                _read(args.app_role_assignments),
            )
            print(json.dumps(requests, indent=2))
            return 0
        result = verify(_read(args.expected), _read(args.application))
    except PlanError as exc:
        print(f"error: {exc}", file=sys.stderr)
        return 2

    print(json.dumps(result, indent=2))
    # `extra` is reported, never fatal: an administrator may have granted
    # something else on the same registration, and this script has no
    # business failing over a permission it did not add.
    return 1 if result["missing"] else 0


if __name__ == "__main__":
    raise SystemExit(main())

PY
}

if [[ ${1:-} == "--list-services" ]]; then
  permission_tool services
  exit 0
fi
if [[ ${1:-} == "--dump-service-catalog" ]]; then
  permission_tool catalog
  exit 0
fi

if [[ $# -eq 0 ]]; then INTERACTIVE_SETUP=1; fi

while [[ $# -gt 0 ]]; do
  case "$1" in
    --redirect-uri) need "$@"; REDIRECT_URI="$2"; shift 2 ;;
    --services) need "$@"; SERVICES="$2"; shift 2 ;;
    --name) need "$@"; NAME="$2"; shift 2 ;;
    --app-id) need "$@"; APP_ID="$2"; shift 2 ;;
    --with-group-access) GROUP_ACCESS=1; shift ;;
    --cert-days) need "$@"; CERT_DAYS="$2"; shift 2 ;;
    --out-dir) need "$@"; OUT_DIR="$2"; shift 2 ;;
    --no-admin-consent) ADMIN_CONSENT=0; shift ;;
    --verify-only) VERIFY_ONLY=1; shift ;;
    --yes|-y) ASSUME_YES=1; shift ;;
    -h|--help)
      sed -n '2,25p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
      exit 0
      ;;
    *) die "unknown argument $1" ;;
  esac
done

command -v python3 >/dev/null || die "Python 3 is not installed"
command -v az >/dev/null || die "the Azure CLI (az) is not installed; run this in Azure Cloud Shell"
command -v curl >/dev/null || die "curl is not installed"
[[ $VERIFY_ONLY -eq 0 || -n "$APP_ID" ]] || die "--verify-only needs --app-id"
if [[ -z "$SERVICES" ]]; then SERVICES="$(permission_tool services --keys-only)"; fi

validate_certificate_pair() {
  local certificate_public_key private_public_key
  openssl x509 -in "$CERT" -noout -checkend 0 >/dev/null 2>&1 \
    || die "$CERT is not a readable, unexpired X.509 certificate"
  certificate_public_key="$(openssl x509 -in "$CERT" -pubkey -noout 2>/dev/null \
    | openssl pkey -pubin -outform DER 2>/dev/null \
    | openssl sha256 2>/dev/null)" \
    || die "could not read the public key from $CERT"
  private_public_key="$(openssl pkey -in "$KEY" -passin pass: -pubout -outform DER 2>/dev/null \
    | openssl sha256 2>/dev/null)" \
    || die "$KEY is not a readable, unencrypted private key"
  [[ "$certificate_public_key" == "$private_public_key" ]] \
    || die "$CERT and $KEY do not contain a matching key pair"
}

if [[ $INTERACTIVE_SETUP -eq 1 ]]; then
  [[ -t 0 ]] || die "download the script, then run it from a terminal"
  echo "Asteria Cloud can configure the Entra application for you."
  echo
  echo "Available services:"
  permission_tool services
  echo
  read -r -p "Services to enable [$SERVICES]: " reply
  if [[ -n "$reply" ]]; then SERVICES="$reply"; fi
  read -r -p "Enable group access for shared files? [y/N] " reply
  if [[ "$reply" == "y" || "$reply" == "Y" ]]; then GROUP_ACCESS=1; fi
fi

if [[ $GROUP_ACCESS -eq 1 ]]; then
  command -v openssl >/dev/null || die "group access needs openssl"
  [[ "$CERT_DAYS" =~ ^[0-9]+$ && "$CERT_DAYS" -gt 0 ]] \
    || die "--cert-days must be a positive integer"
  CERT="$OUT_DIR/acl-cert.pem"
  KEY="$OUT_DIR/acl-key.pem"
  REUSE_CERT=0
  if [[ -e "$CERT" && -e "$KEY" ]]; then
    REUSE_CERT=1
    validate_certificate_pair
  elif [[ -e "$CERT" || -e "$KEY" ]]; then
    die "group access needs both $CERT and $KEY; move the incomplete pair aside first"
  fi
fi

WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT

TENANT_ID="$(az account show --query tenantId -o tsv)" || die "open Azure Cloud Shell again or run 'az login'"
[[ -n "$TENANT_ID" ]] || die "no signed-in tenant"

PLAN_ARGS=(plan --services "$SERVICES")
if [[ $GROUP_ACCESS -eq 1 ]]; then PLAN_ARGS+=(--group-access); fi
permission_tool "${PLAN_ARGS[@]}" > "$WORK/plan.json" || die "the service list was refused"

az ad sp show --id "$GRAPH_API_ID" -o json > "$WORK/graph.json" \
  || die "cannot read the Microsoft Graph service principal in tenant $TENANT_ID"
RESOLVE_ARGS=(resolve --plan "$WORK/plan.json" --graph-principal "$WORK/graph.json")
if [[ $GROUP_ACCESS -eq 1 ]]; then
  az ad sp show --id "$SHAREPOINT_API_ID" -o json > "$WORK/sharepoint.json" \
    || die "cannot read the SharePoint service principal; does this tenant have SharePoint?"
  RESOLVE_ARGS+=(--sharepoint-principal "$WORK/sharepoint.json")
fi

if [[ -n "$APP_ID" ]]; then
  az ad app show --id "$APP_ID" -o json > "$WORK/app.json" \
    || die "no application $APP_ID in tenant $TENANT_ID"
  python3 -c 'import json,sys; print(json.dumps(json.load(open(sys.argv[1])).get("requiredResourceAccess") or []))' \
    "$WORK/app.json" > "$WORK/existing.json"
  RESOLVE_ARGS+=(--existing "$WORK/existing.json")
fi

permission_tool "${RESOLVE_ARGS[@]}" > "$WORK/resolved.json" \
  || die "a permission name did not resolve"
python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(json.dumps(d["requiredResourceAccess"]))' \
  "$WORK/resolved.json" > "$WORK/rra.json"

verify_registration() {
  local app_id="$1"
  say "verifying the registration"
  az ad app show --id "$app_id" -o json > "$WORK/after.json" \
    || die "the registration does not resolve as $app_id"

  python3 - "$WORK/after.json" "$TENANT_ID" "$REDIRECT_URI" <<'PY'
import json, sys
app = json.load(open(sys.argv[1]))
tenant, redirect = sys.argv[2], sys.argv[3]
print(f"    tenant id: {tenant}")
print(f"    client id: {app['appId']}")
print(f"    audience:  {app.get('signInAudience')}")
uris = (app.get("web") or {}).get("redirectUris") or []
print(f"    redirect:  {', '.join(uris) or '(none)'}")
if app.get("signInAudience") != "AzureADMyOrg":
    sys.exit(f"error: the registration is not single tenant ({app.get('signInAudience')})")
if redirect and redirect not in uris:
    sys.exit(f"error: {redirect} is not among the registration's redirect URIs")
PY

  if ! permission_tool verify --expected "$WORK/resolved.json" --application "$WORK/after.json" \
       > "$WORK/verdict.json"; then
    cat "$WORK/verdict.json"
    die "permissions are missing from the registration"
  fi
  python3 - "$WORK/verdict.json" <<'PY'
import json, sys
extra = json.load(open(sys.argv[1]))["extra"]
for api, ids in extra.items():
    print(f"    note: {len(ids)} existing permission(s) on {api} were preserved")
PY

  if [[ $GROUP_ACCESS -eq 1 ]]; then
    python3 - "$WORK/after.json" "$SHAREPOINT_API_ID" <<'PY'
import json, sys
app = json.load(open(sys.argv[1]))
sharepoint = sys.argv[2]
for resource in app.get("requiredResourceAccess") or []:
    if resource["resourceAppId"] == sharepoint and resource.get("resourceAccess"):
        print("    SharePoint's own Sites.Read.All is present")
        break
else:
    sys.exit("error: no SharePoint permission is present; site groups will not resolve")
PY
  fi
}

verify_secret() {
  local secret="$1" client_id="$2" body code attempt
  for attempt in {1..12}; do
    body="$(printf '%s' "$secret" | curl -sS -X POST \
      "https://login.microsoftonline.com/$TENANT_ID/oauth2/v2.0/token" \
      -d "client_id=$client_id" -d "grant_type=client_credentials" \
      --data-urlencode "scope=https://graph.microsoft.com/.default" \
      --data-urlencode "client_secret@-" || true)"
    code="$(printf '%s' "$body" | grep -o 'AADSTS[0-9]*' | head -1 || true)"
    case "$body" in
      *access_token*) say "the client secret completed a token exchange"; return 0 ;;
      *AADSTS7000215*)
        if [[ $attempt -lt 12 ]]; then
          say "the new client secret is not readable yet; waiting for Entra ($attempt/12)"
          sleep 5
          continue
        fi
        ;;
      *invalid_client*)
        echo "error: Entra rejected the client credential (${code:-no AADSTS code in the response})" >&2
        return 1
        ;;
      "") echo "error: no response from the token endpoint" >&2; return 1 ;;
      *) say "Entra accepted the client credential (this permission set issues no app-only token)"; return 0 ;;
    esac
  done
  echo "error: Entra still reports AADSTS7000215 after one minute. Keep the client secret printed above, wait a few minutes, and test it in Asteria Cloud before creating another." >&2
  return 1
}

certificate_is_uploaded() {
  local thumbprint
  thumbprint="$(openssl x509 -in "$CERT" -outform DER 2>/dev/null \
    | openssl sha1 -binary \
    | openssl base64 -A)" \
    || die "$CERT is not a readable X.509 certificate"
  az ad app credential list --id "$APP_ID" --cert \
    --query '[].customKeyIdentifier' -o tsv > "$WORK/certificate-thumbprints.txt" \
    || die "could not inspect the registration's certificates"
  grep -Fxq "$thumbprint" "$WORK/certificate-thumbprints.txt"
}

grant_admin_consent() {
  local client_sp_id="" method url body_path
  for _ in {1..10}; do
    client_sp_id="$(az ad sp show --id "$APP_ID" --query id -o tsv 2>/dev/null || true)"
    if [[ -n "$client_sp_id" ]]; then break; fi
    sleep 2
  done
  [[ -n "$client_sp_id" ]] \
    || die "the enterprise application for $APP_ID did not become readable; rerun with --app-id $APP_ID"

  az rest --method get \
    --url "https://graph.microsoft.com/v1.0/servicePrincipals/$client_sp_id/oauth2PermissionGrants" \
    -o json > "$WORK/delegated-grants.json" \
    || die "Microsoft Graph could not read existing delegated grants. The registration remains as $APP_ID; rerun with --app-id $APP_ID after fixing the signed-in account's access."
  az rest --method get \
    --url "https://graph.microsoft.com/v1.0/servicePrincipals/$client_sp_id/appRoleAssignments" \
    -o json > "$WORK/app-role-assignments.json" \
    || die "Microsoft Graph could not read existing application grants. The registration remains as $APP_ID; rerun with --app-id $APP_ID after fixing the signed-in account's access."
  permission_tool consent \
    --resolved "$WORK/resolved.json" \
    --client-service-principal-id "$client_sp_id" \
    --delegated-grants "$WORK/delegated-grants.json" \
    --app-role-assignments "$WORK/app-role-assignments.json" \
    > "$WORK/consent-requests.json" \
    || die "existing consent grants are ambiguous. The registration remains as $APP_ID; inspect it in Entra before rerunning with --app-id $APP_ID."

  python3 - "$WORK/consent-requests.json" "$WORK" \
    > "$WORK/consent-commands.tsv" <<'PY'
import json, sys
from pathlib import Path

requests = json.load(open(sys.argv[1]))
work = Path(sys.argv[2])
for index, request in enumerate(requests):
    body = work / f"consent-{index}.json"
    body.write_text(json.dumps(request["body"]))
    print(f'{request["method"]}\t{request["url"]}\t{body}')
PY

  while IFS=$'\t' read -r method url body_path; do
    [[ -n "$method" ]] || continue
    az rest --method "$method" --url "$url" --body "@$body_path" -o none \
      || die "admin consent failed through Microsoft Graph. The registration remains as $APP_ID. Confirm the signed-in account can grant tenant-wide consent, then rerun this script with --app-id $APP_ID and the same services."
  done < "$WORK/consent-commands.tsv"

  for _ in {1..10}; do
    if az rest --method get \
      --url "https://graph.microsoft.com/v1.0/servicePrincipals/$client_sp_id/oauth2PermissionGrants" \
      -o json > "$WORK/delegated-grants-after.json" \
      && az rest --method get \
      --url "https://graph.microsoft.com/v1.0/servicePrincipals/$client_sp_id/appRoleAssignments" \
      -o json > "$WORK/app-role-assignments-after.json" \
      && permission_tool consent \
      --resolved "$WORK/resolved.json" \
      --client-service-principal-id "$client_sp_id" \
      --delegated-grants "$WORK/delegated-grants-after.json" \
      --app-role-assignments "$WORK/app-role-assignments-after.json" \
      > "$WORK/remaining-consent.json"; then
      if [[ "$(python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1]))))' "$WORK/remaining-consent.json")" -eq 0 ]]; then
        say "admin consent verified"
        return
      fi
    fi
    sleep 2
  done
  die "admin consent did not become readable in Microsoft Graph. The registration remains as $APP_ID; rerun with --app-id $APP_ID before creating a secret."
}

if [[ $VERIFY_ONLY -eq 1 ]]; then
  verify_registration "$APP_ID"
  say "done"
  exit 0
fi

echo "Tenant:        $TENANT_ID"
echo "Registration:  ${APP_ID:-new application \"$NAME\"}"
echo "Redirect URI:  $REDIRECT_URI"
echo "Services:      $SERVICES"
python3 - "$WORK/plan.json" "$WORK/resolved.json" <<'PY'
import json, sys
plan = json.load(open(sys.argv[1]))
resolved = json.load(open(sys.argv[2]))
print("Delegated:     " + " ".join(plan["delegated"]))
for api, names in plan["application"].items():
    print(f"Application:   {api}: {' '.join(names)}")
print("Admin consent: " + (", ".join(resolved["adminConsent"]) or "none of these need it"))
print(f"Adding:        {resolved['added']} permission(s)")
PY

if [[ $ASSUME_YES -eq 0 ]]; then
  [[ -t 0 ]] || die "not a terminal; pass --yes once you have read the plan above"
  read -r -p "Apply this to tenant $TENANT_ID? [y/N] " reply
  [[ "$reply" == "y" || "$reply" == "Y" ]] || die "cancelled"
fi

if [[ -z "$APP_ID" ]]; then
  say "creating the registration"
  APP_ID="$(az ad app create \
    --display-name "$NAME" \
    --sign-in-audience AzureADMyOrg \
    --web-redirect-uris "$REDIRECT_URI" \
    --required-resource-accesses "@$WORK/rra.json" \
    --query appId -o tsv)" || die "az ad app create failed"
  [[ -n "$APP_ID" ]] || die "az ad app create returned no application id"
else
  say "updating registration $APP_ID"
  python3 - "$WORK/app.json" "$REDIRECT_URI" > "$WORK/uris.txt" <<'PY'
import json, sys
app = json.load(open(sys.argv[1]))
uris = list((app.get("web") or {}).get("redirectUris") or [])
if sys.argv[2] and sys.argv[2] not in uris:
    uris.append(sys.argv[2])
print("\n".join(uris))
PY
  REDIRECT_URIS=()
  while IFS= read -r uri; do
    if [[ -n "$uri" ]]; then REDIRECT_URIS+=("$uri"); fi
  done < "$WORK/uris.txt"
  az ad app update --id "$APP_ID" \
    --web-redirect-uris "${REDIRECT_URIS[@]}" \
    --required-resource-accesses "@$WORK/rra.json" || die "az ad app update failed"
fi

if ! az ad sp show --id "$APP_ID" -o none >/dev/null 2>&1; then
  az ad sp create --id "$APP_ID" -o none \
    || die "could not create the enterprise application for $APP_ID"
fi

CONSENT_COUNT="$(python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1]))["adminConsent"]))' "$WORK/resolved.json")"
if [[ $ADMIN_CONSENT -eq 1 && $CONSENT_COUNT -gt 0 ]]; then
  say "granting admin consent"
  grant_admin_consent
elif [[ $CONSENT_COUNT -gt 0 ]]; then
  echo "    admin consent remains to be granted. Rerun without --no-admin-consent:"
  echo "      bash ${BASH_SOURCE[0]} --app-id $APP_ID --services $SERVICES --yes"
else
  say "the selected delegated permissions need no tenant-wide admin consent"
fi

if [[ $GROUP_ACCESS -eq 1 ]]; then
  mkdir -p -- "$OUT_DIR"
  if [[ $REUSE_CERT -eq 1 ]]; then
    say "reusing the existing group-access certificate and private key"
  else
    say "generating the group-access certificate"
    openssl req -x509 -newkey rsa:2048 -nodes -days "$CERT_DAYS" \
      -subj "/CN=asteria-acl" -keyout "$KEY" -out "$CERT" 2>/dev/null \
      || die "openssl could not generate the certificate"
  fi
  chmod 600 "$KEY"
  if certificate_is_uploaded; then
    say "the group-access certificate is already uploaded"
  else
    say "uploading the group-access certificate"
    az ad app credential reset --id "$APP_ID" --append \
      --cert "@$CERT" --years "$((CERT_DAYS / 365 + 1))" -o none \
      || die "could not upload the certificate"
  fi
fi

verify_registration "$APP_ID"

say "creating a client secret"
SECRET="$(az ad app credential reset --id "$APP_ID" --append --years 2 \
  --display-name "asteria-cloud" --query password -o tsv)" \
  || die "could not create a client secret"
[[ -n "$SECRET" ]] || die "Entra returned an empty client secret"

cat <<EOF

Save these values now. Microsoft shows the client secret only once:

  Client ID:      $APP_ID
  Tenant ID:      $TENANT_ID
  Client secret:  $SECRET
EOF
if [[ $GROUP_ACCESS -eq 1 ]]; then
  cat <<EOF

Certificate for group access (certificate followed by private key):
EOF
  cat "$CERT" "$KEY"
  cat <<EOF

The private key also remains in $KEY with mode 0600. Store it as securely as
the client secret and remove the Cloud Shell files after saving it in Asteria Cloud.
EOF
fi
verify_secret "$SECRET" "$APP_ID" || exit 1
echo
echo "Setup complete. Paste the values above into Asteria Cloud."
say "done"
