# Authentication

Union.ai self-hosted deployments use [OpenID Connect (OIDC)](https://openid.net/specs/openid-connect-core-1_0.html) for user authentication and [OAuth 2.0](https://tools.ietf.org/html/rfc6749) for service-to-service authorization.

Unlike serverless and BYOC deployments where Union.ai manages authentication for you, **self-hosted deployments require you to create and manage OAuth applications in your own identity provider** (e.g. Okta, Microsoft Entra ID, Google Workspace, or any OIDC-compliant provider). Union.ai does not provision or manage these applications — you are responsible for their lifecycle, credential rotation, and access policies.

> [!NOTE]
> This guide covers authentication for **self-hosted** deployments where you manage both the control plane and data plane. For **self-managed** deployments (Union.ai-hosted control plane), authentication is handled automatically via `uctl selfserve provision-dataplane-resources` and `uctl create apikey`.

## Overview

Self-hosted authentication requires creating **five OAuth2 client applications** in your own identity provider (plus an optional sixth for CI/CD). Each application serves a different authentication flow:

| # | Application | Type | Grant types | Purpose |
|---|-------------|------|-------------|---------|
| 1 | Browser login | Confidential (web) | `authorization_code`, `refresh_token`, `client_credentials` | Console/web UI login |
| 2 | CLI | Public (native) | `authorization_code`, `refresh_token`, `device_code` | `uctl` / `flytectl` CLI authentication via PKCE |
| 3 | Service-to-service | Confidential (service) | `client_credentials` | Control plane inter-service communication through NGINX |
| 4 | Operator | Confidential (service) | `client_credentials` | Data plane operator, propeller, and cluster-resource-sync authentication to control plane |
| 5 | EAGER | Confidential (service) | `client_credentials` | Task pod authentication (EAGER_API_KEY) |
| 6 | CI/CD _(optional)_ | Confidential (service) | `client_credentials` | Non-interactive workflow deployment from CI/CD pipelines |

> [!NOTE]
> App 6 (CI/CD) is only needed if you deploy workflows from automated pipelines. See the [CI/CD integration](https://www.union.ai/docs/v2/union/deployment/selfhosted/authentication/operations/cicd) guide for full setup instructions.

> [!IMPORTANT]
> **With more than one data plane, it's best practice to create a separate Operator (App 4) and EAGER (App 5) client for each data plane cluster.** Each data plane then authenticates as its own identity — one you can grant cluster-management permission on only its own cluster — and mints its own `EAGER_API_KEY`. Sharing a single Operator client forces one identity to hold cluster-management rights on every cluster, and sharing a single EAGER client causes rotation interference: each data plane's key request rotates the shared secret and invalidates the others. Apps 1–3 (and the optional App 6) stay single and control-plane-wide.

## Identity provider requirements

You must use an OIDC-compliant identity provider that you manage outside of Union.ai. Any standards-compliant provider will work. Union.ai uses [Okta](https://www.okta.com/) for its internal deployments, but you can use whichever provider your organization already uses.

Your identity provider must support:

1. **OpenID Connect Discovery** — `/.well-known/openid-configuration` or `/.well-known/oauth-authorization-server` endpoint
2. **Authorization Code flow** — for browser and CLI login
3. **Client Credentials flow** — for service-to-service tokens
4. **PKCE** (Proof Key for Code Exchange) — for the CLI public client
5. **Custom scopes** — ability to create an `all` scope (or equivalent)
6. **Custom claims** — ability to emit `sub` and `preferred_username` claims in access tokens. An identity type claim (`identitytype` or equivalent) is recommended for authorization.

### Authorization server setup

Create a custom authorization server (or equivalent) in your identity provider. The setup differs by provider:

### Okta

Create a **Custom Authorization Server** in Okta:

- **Audience**: `https://<your-domain>` (the control plane ingress domain)
- **Default scope**: `all`
- **Metadata URL**: `.well-known/oauth-authorization-server` (Okta-specific, not the standard `openid-configuration`)
- **Claims** (add as access token claims):
  - `sub` — Okta populates this natively. For client_credentials tokens, `sub` equals the app's Client ID.
  - `identitytype` — set to `"user"` for user tokens, `"app"` for client_credentials tokens
  - `preferred_username` — set to the user's login for user tokens, or the app's Client ID for app tokens

### Entra ID

Register an **App Registration** in Microsoft Entra ID:

- **App ID URI**: `api://<app-name>` (this becomes the audience)
- **Metadata URL**: `.well-known/openid-configuration` (standard OIDC)
- **Scopes**: The `/.default` scope is used automatically for client_credentials and CLI flows. No custom scopes are required — browser login uses standard OIDC scopes only.
- **Claims** — configure via the app manifest's `optionalClaims`:
  - `sub` — Entra populates this natively. For client_credentials tokens, `sub` equals the **Service Principal Object ID** (not the Client ID).
  - `idtyp` — add as an optional access token claim **on the browser login app registration (App 1)**, since it is the resource server. Emits `"app"` for client_credentials tokens (maps to the `identitytype` concept).
  - `preferred_username` — included by default for user tokens

> [!WARNING]
> Entra ID uses `sub` = Service Principal Object ID for client_credentials tokens, not the Client ID. When configuring trusted identities for service-to-service auth, use the SP Object ID (found in Enterprise Applications, not App Registrations).

> [!NOTE]
> Entra ID v2.0 tokens always include the `sub` claim, so no fallback claim configuration is required. If you need to override the subject resolution (e.g. to use `oid` or `client_id` instead of `sub`), see the [Subject claim requirements](#subject-claim-requirements) section below for the `subjectClaimNames` fallback chain.

> [!NOTE]
> Entra ID scope usage by flow:
> - **Browser login** (authorization_code): standard OIDC scopes only (`profile`, `openid`, `offline_access`) — the IdP returns a plain ID token
> - **CLI** (authorization_code + PKCE): `api://<app-name>/.default`
> - **Service-to-service** (client_credentials): `api://<app-name>/.default`

### Generic OIDC

For other OIDC providers (Keycloak, Authentik, Auth0, etc.):

- **Audience**: `https://<your-domain>` or a custom resource identifier
- **Metadata URL**: Usually `.well-known/openid-configuration`
- **Scopes**: Create an `all` scope (or use your provider's default scope)
- **Claims**: Ensure access tokens include:
  - `sub` — a stable identifier for the authenticated principal
  - `preferred_username` — display name for identity injection
  - An identity type claim is optional but recommended for authorization

If your IdP cannot emit an `identitytype` claim, see the [identity type claim requirements](#identity-type-claim-requirements) section below.

If your IdP's client_credentials tokens omit the `sub` claim, configure `subjectClaimNames` to specify a fallback chain (e.g., `["sub", "client_id", "azp"]`).

### Identity type claim requirements

Union.ai uses an identity type claim to distinguish human users from service applications. This distinction is **required for Union (built-in RBAC) authorization** and affects how access control decisions are made.

Your IdP must emit a claim that maps to the `identitytype` concept, with values that distinguish user tokens from application tokens. The claim name and values are configurable:

| Provider | Claim name | User value | App value | Configuration |
|----------|-----------|------------|-----------|---------------|
| Okta | `identitytype` | `"user"` | `"app"` | Custom access token claim on authorization server |
| Entra ID | `idtyp` | (not emitted) | `"app"` | Enable via optional claims in app manifest. Map with `identityTypeClaimsForApps: {idtyp: ["app"]}` |
| Generic | varies | varies | varies | Configure `identityTypeClaimsForApps` to map your claim name and values |

> [!WARNING]
> **Union authorization mode requires identity type resolution.** If your IdP cannot emit any claim that distinguishes users from applications, you must either:
> 1. Set `global.USE_EXTERNAL_IDENTITY: true` — the platform will infer identity type from the authentication context (e.g., whether the token was issued via authorization_code or client_credentials flow). This works for basic cases but may not cover all scenarios.
> 2. Use **External authorization mode** instead of Union mode — your external authorization server can determine identity type from the JWT payload, `sub` claim, or any other token attribute directly, without relying on the platform's identity type resolution.
>
> Without identity type resolution, Union authorization cannot distinguish user requests from service account requests, which may result in incorrect access control decisions.

### Subject claim requirements

Union.ai uses the JWT `sub` claim as the **primary identifier** for all callers — users and service accounts alike. This value is used for:

- **Authorization decisions** — matching callers to roles and permissions
- **Trusted identity validation** — verifying internal service-to-service callers
- **Audit logging** — recording who performed each action
- **Resource ownership** — the "Owned By" relationship in the console

> [!WARNING]
> The `sub` claim value must be **stable and unique** per principal. If your IdP returns different `sub` values for the same user across token refreshes, authorization and ownership tracking will break.

> [!WARNING]
> The `sub` claim is critical for **all** OAuth applications, including service-to-service (App 3), operator (App 4), and EAGER (App 5). If your IdP does not include `sub` in client credentials tokens, service-to-service authentication will fail with `x-user-subject header not found`. Verify that all five applications produce tokens with a `sub` claim before deploying.

**Your IdP must emit a `sub` claim in all access tokens.** If your IdP's client_credentials tokens use a different claim for the caller identity (or omit `sub` entirely), configure `subjectClaimNames` to specify a fallback chain:

```yaml
# In flyte.configmap.adminServer.auth.appAuth.externalAuthServer:
subjectClaimNames:
  - sub          # Standard OIDC subject (tried first)
  - client_id    # OAuth2 client ID (common fallback)
  - azp          # Authorized party (alternative)
```

The platform tries each claim in order and uses the first non-empty value as the caller's identity.

> [!NOTE]
> **Provider-specific `sub` values:**
> - **Okta**: `sub` equals the Client ID for client_credentials tokens and the user's Okta ID for user tokens.
> - **Entra ID**: `sub` equals the **Service Principal Object ID** for client_credentials tokens (not the Client ID). Find this in Entra ID > Enterprise Applications > your app > Object ID.
> - When configuring trusted identities for internal services (e.g., `INTERNAL_SUBJECT_ID`), use the value that your IdP places in the `sub` claim — not necessarily the Client ID.

## Step 1: Create OAuth2 applications

### Application 1: Browser login (Confidential)

Used by the web console for user authentication.

| Property | Value |
|----------|-------|
| Type | Web (confidential client) |
| Grant types | `authorization_code`, `refresh_token`, `client_credentials` |
| Redirect URI | `https://<your-domain>/callback` |
| Post-logout redirect URI | `https://<your-domain>/logout` |
| Scopes | `openid`, `profile`, `offline_access` |

Note the **Client ID** (set as `flyte.configmap.adminServer.auth.userAuth.openId.clientId`) and the **Client Secret** (stored in the dedicated `flyteadmin-oidc-client-secret` Secret under the key `client_secret`, read via `userAuth.openId.clientSecretFile` — see [Step 3](#step-3-create-kubernetes-secrets-control-plane)).

### Application 2: CLI (Public)

Used by `uctl` and `flytectl` for CLI-based authentication with PKCE.

| Property | Value |
|----------|-------|
| Type | Native (public client) |
| Grant types | `authorization_code`, `refresh_token`, `device_code` |
| Redirect URIs | `http://localhost:53593/callback`, `http://localhost:12345/callback` |
| PKCE | Required |
| Client authentication | None (PKCE only, no client secret) |

Note the **Client ID** (set as `flyte.configmap.adminServer.auth.appAuth.thirdPartyConfig.flyteClient.clientId`).

### Application 3: Service-to-service (Confidential)

Used by control plane services (executions, cluster, identity, etc.) to authenticate with each other through NGINX when OIDC is enabled.

| Property | Value |
|----------|-------|
| Type | Service (confidential client) |
| Grant types | `client_credentials` |

Note the **Client ID** (used as `INTERNAL_CLIENT_ID`) and the **Client Secret** (stored in Kubernetes secrets).

### Application 4: Operator (Confidential)

Used by data plane services (operator, propeller, cluster-resource-sync) to authenticate to the control plane.

| Property | Value |
|----------|-------|
| Type | Service (confidential client) |
| Grant types | `client_credentials` |

Note the **Client ID** (used as `AUTH_CLIENT_ID` in data plane configuration) and the **Client Secret** (stored in Kubernetes secrets).

> [!NOTE]
> Create one Operator app **per data plane cluster** (for example `<org>-operator-dp-1`, `<org>-operator-dp-2`). Each data plane uses its own Client ID as `AUTH_CLIENT_ID`, so it authenticates as a distinct identity that can be granted cluster-management permission on only its own cluster.

### Application 5: EAGER (Confidential)

Used for task pod authentication. The encoded credentials form the `EAGER_API_KEY`.

| Property | Value |
|----------|-------|
| Type | Service (confidential client) |
| Grant types | `client_credentials` |

Note the **Client ID** and **Client Secret** — these are encoded into the EAGER_API_KEY.

> [!NOTE]
> Create one EAGER app **per data plane cluster**. Each data plane's operator mints its `EAGER_API_KEY` against its own EAGER client, so a key request from one data plane never rotates or invalidates another's.

## Step 2: Configure control plane

Authentication is configured in the `flyte.configmap.adminServer.auth` block in your control plane Helm values. This block defines how the admin service validates tokens, which clients are trusted, and how browser login works.

You also need to set a few global variables for service-to-service authentication:

```yaml
global:
  INTERNAL_CLIENT_ID: "<service-to-service-client-id>"  # App 3
  AUTH_TOKEN_URL: "<token-endpoint-url>"                 # OAuth2 token endpoint
  OIDC_S2S_SCOPE: ""                                    # Leave empty for Okta, set to "api://<app>/.default" for Entra ID
```

Then configure the auth block. Select your identity provider below:

### Okta

```yaml
flyte:
  configmap:
    adminServer:
      server:
        security:
          useAuth: true
      auth:
        appAuth:
          authServerType: External
          externalAuthServer:
            baseUrl: "https://dev-123456.okta.com/oauth2/default"
            metadataUrl: ".well-known/oauth-authorization-server"
            allowedAudience:
              - "https://<your-domain>"
          thirdPartyConfig:
            flyteClient:
              clientId: "<cli-client-id>"           # App 2
              redirectUri: "http://localhost:53593/callback"
              scopes:
                - all
        userAuth:
          openId:
            baseUrl: "https://dev-123456.okta.com/oauth2/default"
            clientId: "<browser-login-client-id>"   # App 1
            scopes:
              - profile
              - openid
              - offline_access
          cookieSetting:
            sameSitePolicy: LaxMode
            domain: "<your-domain>"
```

Set globals:
```yaml
global:
  INTERNAL_CLIENT_ID: "<service-to-service-client-id>"
  AUTH_TOKEN_URL: "https://dev-123456.okta.com/oauth2/default/v1/token"
  OIDC_S2S_SCOPE: ""   # Okta defaults to "all"
```

### Entra ID

```yaml
flyte:
  configmap:
    adminServer:
      server:
        security:
          useAuth: true
      auth:
        appAuth:
          authServerType: External
          externalAuthServer:
            baseUrl: "https://login.microsoftonline.com/<tenant-id>/v2.0"
            metadataUrl: ".well-known/openid-configuration"
            allowedAudience:
              - "api://<app-name>"
              - "<browser-login-client-id>"    # App 1 Client ID
          identityTypeClaimsForApps:
            idtyp:
              - app
          thirdPartyConfig:
            flyteClient:
              clientId: "<cli-client-id>"           # App 2
              redirectUri: "http://localhost:53593/callback"
              scopes:
                - "api://<app-name>/.default"
              audience: "api://<app-name>"
        userAuth:
          openId:
            baseUrl: "https://login.microsoftonline.com/<tenant-id>/v2.0"
            clientId: "<browser-login-client-id>"   # App 1
            scopes:
              - profile
              - openid
              - offline_access
          cookieSetting:
            sameSitePolicy: LaxMode
            domain: "<your-domain>"
          idpQueryParameter: "idp"
```

> [!WARNING]
> After creating service apps (Apps 3-5), you must **grant admin consent** for their App Role assignments in the Azure portal (**Enterprise Applications > your app > Permissions > Grant admin consent**) or via `az ad app permission admin-consent`. Without admin consent, client_credentials token requests will fail.

Set globals:
```yaml
global:
  INTERNAL_CLIENT_ID: "<service-to-service-client-id>"
  AUTH_TOKEN_URL: "https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/token"
  OIDC_S2S_SCOPE: "api://<app-name>/.default"
```

> [!NOTE]
> `INTERNAL_SUBJECT_ID` defaults to `INTERNAL_CLIENT_ID` for backward compatibility. For Entra ID, where the token `sub` claim is the Service Principal Object ID (not the Client ID), set `INTERNAL_SUBJECT_ID` to the SP Object ID. Find this in **Entra ID > Enterprise Applications > your app > Object ID**.

### Generic OIDC

```yaml
flyte:
  configmap:
    adminServer:
      server:
        security:
          useAuth: true
      auth:
        appAuth:
          authServerType: External
          externalAuthServer:
            baseUrl: "<issuer-url>"
            metadataUrl: ".well-known/openid-configuration"
            allowedAudience:
              - "<audience>"
          thirdPartyConfig:
            flyteClient:
              clientId: "<cli-client-id>"           # App 2
              redirectUri: "http://localhost:53593/callback"
              scopes:
                - all
        userAuth:
          openId:
            baseUrl: "<issuer-url>"
            clientId: "<browser-login-client-id>"   # App 1
            scopes:
              - profile
              - openid
              - offline_access
          cookieSetting:
            sameSitePolicy: LaxMode
            domain: "<your-domain>"
```

Set globals:
```yaml
global:
  INTERNAL_CLIENT_ID: "<service-to-service-client-id>"
  AUTH_TOKEN_URL: "<issuer-url>/token"      # Your IdP's token endpoint
  OIDC_S2S_SCOPE: ""                        # Set if your IdP requires a specific scope for client_credentials
```

If your IdP's client_credentials tokens don't include a `sub` claim, add:
```yaml
            subjectClaimNames:
              - sub
              - client_id
              - azp
```

> [!NOTE]
> Setting `useAuth: true` is required for the `/login`, `/callback`, and `/me` endpoints to register. Without this, auth endpoints will return 404.

## Step 3: Create Kubernetes secrets (control plane)

The control plane needs secrets for the browser login app (App 1) and the service-to-service app (App 3):

```shell
# Browser-login (App 1) OIDC client secret, in its own dedicated secret.
# flyteadmin reads it via userAuth.openId.clientSecretFile (configured below).
kubectl create secret generic flyteadmin-oidc-client-secret \
  --from-literal=client_secret='<BROWSER_LOGIN_CLIENT_SECRET>' \
  -n <controlplane-namespace>

# Add service-to-service client secret to the controlplane secrets
kubectl create secret generic <controlplane-secrets> \
  --from-literal=pass.txt='<DB_PASSWORD>' \
  --from-literal=client_secret='<SERVICE_TO_SERVICE_CLIENT_SECRET>' \
  -n <controlplane-namespace> --dry-run=client -o yaml | kubectl apply -f -
```

Point flyteadmin at the browser-login secret by mounting it and setting `clientSecretFile` in your control plane values (see the chart's `examples/values.oidc-client-secret.yaml`):

```yaml
flyte:
  flyteadmin:
    # additionalVolumes / additionalVolumeMounts are list-valued (Helm replaces,
    # not merges) — keep any chart-default entries you rely on alongside this one.
    additionalVolumes:
      - name: oauth-client-secret
        secret:
          secretName: flyteadmin-oidc-client-secret
    additionalVolumeMounts:
      - name: oauth-client-secret
        mountPath: /etc/secrets/oauth
        readOnly: true
  configmap:
    adminServer:
      auth:
        userAuth:
          openId:
            clientSecretFile: /etc/secrets/oauth/client_secret
```

> [!NOTE]
> flyteadmin's `flyte-admin-secrets` (token-signing and cookie keys) is created automatically by an init container — you don't create it, and the browser-login client secret does **not** go there. Keeping the OIDC client secret in its own dedicated secret avoids colliding with those auto-generated keys.

> [!NOTE]
> For production, use External Secrets Operator or a similar tool to sync secrets from your cloud provider's secret manager (AWS Secrets Manager, GCP Secret Manager, Azure Key Vault).

## Step 4: Configure data plane

> [!IMPORTANT]
> Repeat this step for **each** data plane, using that data plane's own Operator (App 4) client ID and secret. Every data plane gets its own `AUTH_CLIENT_ID` and its own `union-secret-auth` secret in its namespace.

Add the operator client ID to your data plane overrides file:

```yaml
global:
  AUTH_CLIENT_ID: "<operator-client-id>"  # App 4
```

Create the data plane auth secret:

```shell
kubectl create secret generic union-secret-auth \
  --from-literal=client_secret='<OPERATOR_CLIENT_SECRET>' \
  -n <dataplane-namespace>
```

## Step 5: Configure EAGER_API_KEY

The EAGER_API_KEY is a base64-encoded string containing the EAGER app credentials. It enables task pods to authenticate to the control plane.

> [!IMPORTANT]
> Generate a distinct `EAGER_API_KEY` for **each** data plane from that data plane's own EAGER (App 5) credentials, and create the secret in that data plane's namespace.

Generate the key:

```shell
# Format: base64("<domain>:<eager-client-id>:<eager-client-secret>:")
echo -n "<your-domain>:<eager-client-id>:<eager-client-secret>:" | base64
```

Create the Kubernetes secret in the data plane namespace:

```shell
kubectl create secret generic <eager-secret-name> \
  --from-literal=<eager-secret-key>='<BASE64_ENCODED_EAGER_API_KEY>' \
  -n <dataplane-namespace>
```

> [!NOTE]
> The exact secret name and key depend on your deployment's embedded K8s secret manager configuration. The secret name is typically an MD5 hash of a logical identifier. Contact Union.ai support for the exact values for your organization.

### Provision the task-pod key automatically with the operator

Rather than encoding the `EAGER_API_KEY` and creating the data plane secret by hand, you can let the data plane operator mint it and write it to the task-pod secret store, where the pod webhook injects it into task pods. Enable it in the data plane Helm values:

```yaml
config:
  operator:
    apiKey:
      enabled: true
```

This relies on the embedded Kubernetes secret manager (`proxy.secretManager.enabled`, enabled by default). The operator scopes the key to its own cluster, so in a multi-data-plane organization each data plane provisions and rotates its own `EAGER_API_KEY` without overwriting another's. With this enabled you skip the manual `base64` encoding and `kubectl create secret` steps above.

> [!NOTE]
> `config.operator.apiKey.enabled` is currently opt-in (default `false`) and is expected to become the default in a future chart release. It still requires the EAGER (App 5) credentials to be resolvable on the control plane — either a registered EAGER client or a seeded `apiKeyOverrides` entry (see below).

## Seed EAGER credentials without creating an OAuth app (`apiKeyOverrides`)

Some identity providers cannot create service (`client_credentials`) OAuth applications programmatically, or you may run a control plane with no identity-provider integration at all. In that case the control plane cannot register an EAGER client on demand when a task pod requests its key.

Instead of registering an app, **seed** the EAGER client credentials as a Kubernetes Secret and point the identity service at it with `apiKeyOverrides`. When a key request matches an override, the identity service returns the seeded credentials from the mounted Secret rather than creating a new OAuth application in your IdP.

Configure overrides in your control plane Helm values:

```yaml
services:
  identity:
    apiKeyOverrides:
      - key: EAGER_API_KEY
        existingSecret:
          name: eager-default-creds  # no clusterName — control-plane-wide fallback
      - key: EAGER_API_KEY
        clusterName: dp-1            # this override applies only to the dp-1 data plane
        existingSecret:
          name: eager-dp-1-creds     # Secret holding the seeded EAGER client credentials
      - key: EAGER_API_KEY
        clusterName: dp-2
        existingSecret:
          name: eager-dp-2-creds
```

- **`key`** — the system key to override (`EAGER_API_KEY`).
- **`clusterName`** _(optional)_ — scopes the override to one data plane. A key request resolves on `(organization, key, clusterName)`, preferring the entry that matches the requesting data plane's cluster and falling back to a nameless entry (the control-plane-wide default, shown first above). With multiple data planes, give each its own `clusterName` entry and seeded Secret so their credentials stay independent.
- **`existingSecret.name`** — a Secret you create in the control plane namespace holding the seeded EAGER client's ID and secret. By default the chart reads the keys `client_id` and `client_secret`; set `clientIdKey` / `clientSecretKey` if your Secret uses different keys.

Create one seeded Secret per data plane:

```shell
kubectl create secret generic eager-dp-1-creds \
  --from-literal=client_id='<EAGER_CLIENT_ID>' \
  --from-literal=client_secret='<EAGER_CLIENT_SECRET>' \
  -n <controlplane-namespace>
```

With an override in place you do not create App 5 in your IdP for that data plane — the seeded credentials serve its `EAGER_API_KEY` directly.

## Step 6: Deploy

Deploy or upgrade both the control plane and data plane with the updated configurations:

```shell
# Upgrade control plane
helm upgrade unionai-controlplane unionai/controlplane \
  --namespace <controlplane-namespace> \
  -f values.<cloud>.yaml \
  -f my-overrides.yaml \
  --skip-crds --timeout 15m --wait

# Upgrade data plane
helm upgrade unionai-dataplane unionai/dataplane \
  --namespace <dataplane-namespace> \
  -f values.<cloud>.yaml \
  -f dataplane-overrides.yaml \
  --skip-crds --timeout 10m --wait
```

## Verification

```shell
# Check admin service logs for auth initialization
kubectl logs -n <controlplane-namespace> deploy/<admin-service> | grep -i auth

# Test the /me endpoint (should return 401 without a token)
kubectl exec -n <controlplane-namespace> deploy/<admin-service> -- \
  curl -s -o /dev/null -w "%{http_code}" \
  https://<controlplane-ingress>.<controlplane-namespace>.svc.cluster.local/me -k

# Test CLI login
uctl config init --host https://<your-domain>
uctl get project

# Check data plane operator auth
kubectl logs -n <dataplane-namespace> -l app.kubernetes.io/name=operator --tail=50 | grep -i "token\|auth"
```

## Summary of secrets

| Secret name | Namespace | Keys | Source |
|-------------|-----------|------|--------|
| `flyteadmin-oidc-client-secret` | `<controlplane-namespace>` | `client_secret` | Browser login app (App 1) secret |
| `flyte-admin-secrets` | `<controlplane-namespace>` | token-signing / cookie keys | Auto-generated by the flyteadmin init container (do not create) |
| `<controlplane-secrets>` | `<controlplane-namespace>` | `pass.txt`, `client_secret` | DB password, Service-to-service app (App 3) secret |
| `union-secret-auth` | `<dataplane-namespace>` | `client_secret` | Operator app (App 4) secret |
| EAGER secret | `<dataplane-namespace>` | varies | EAGER app (App 5) encoded key |

> [!NOTE]
> `union-secret-auth` and the EAGER secret are **per data plane** — each data plane holds its own copy in its own namespace, sourced from that data plane's Operator (App 4) and EAGER (App 5) clients. If instead you seed EAGER credentials via `apiKeyOverrides` (see the section above), those Secrets live in the control plane namespace.

## Self-hosted vs. self-managed authentication

| Aspect | Self-hosted | Self-managed |
|--------|------------|--------------|
| OAuth app creation | Manual — create all 5 apps | Automatic — `uctl selfserve provision-dataplane-resources` creates apps 1-3 |
| EAGER_API_KEY | Manual — encode and create secret | Automatic — `uctl create apikey` generates and provisions |
| Control plane auth | Configure via Helm values | Managed by Union.ai |
| Data plane auth | Configure `AUTH_CLIENT_ID` and secret | Provisioned by `uctl selfserve` |

## Troubleshooting

### Admin service auth endpoints return 404

Ensure `useAuth: true` is set under `flyte.configmap.adminServer.server.security`. Without this, the `/login`, `/callback`, and `/me` endpoints are not registered.

### Token validation fails with "audience mismatch"

The `allowedAudience` in the admin service configuration must include `https://<your-domain>`. This should match the audience configured on your authorization server.

### Data plane cannot authenticate to control plane

```shell
# Verify AUTH_CLIENT_ID is set
kubectl get configmap -n <dataplane-namespace> -o yaml | grep -i auth_client

# Check that union-secret-auth exists
kubectl get secret union-secret-auth -n <dataplane-namespace> \
  -o jsonpath='{.data.client_secret}' | base64 -d

# Check operator logs
kubectl logs -n <dataplane-namespace> -l app.kubernetes.io/name=operator --tail=50 \
  | grep -i "auth\|token\|401"
```

### CLI login fails

Ensure the CLI app (App 2) redirect URIs include `http://localhost:53593/callback` and PKCE is enabled. Test with:

```shell
uctl config init --host https://<your-domain>
uctl get project
```

### Entra ID: `AADSTS1002012` invalid_scope for service-to-service

Client_credentials flows in Entra ID require the `/.default` scope. Ensure `OIDC_S2S_SCOPE` is set to `api://<app-name>/.default` in your globals.

### Subject not found in token

If flyteadmin logs show `subject claim not found`, your IdP's client_credentials tokens may not include a `sub` claim. Configure `subjectClaimNames` in the auth block to specify a fallback chain (e.g., `["sub", "client_id"]`).

---
**Source**: https://github.com/unionai/unionai-docs/blob/main/content/deployment/selfhosted/authentication.md
**HTML**: https://www.union.ai/docs/v2/union/deployment/selfhosted/authentication/
