# Authentication

> Authenticate with the Tidal Control API. The OAuth 2.0 Device Authorization Grant for scripts and CLI tools, plus read-only service accounts for automation.

Source: https://tidalcontrol.com/docs/api/authentication

The Tidal Control API uses **Bearer tokens** for authentication. Tokens are issued by your tenant's Keycloak realm using the **OAuth 2.0 Device Authorization Grant** (RFC 8628). This flow is designed for scripts and CLI tools that cannot open a browser window themselves, and the token acts as you, with your permissions.

If your integration runs unattended (a scheduled export into a BI tool, a server-to-server sync), use a read-only **service account** instead. See **Machine-to-machine authentication** at the end of this page.

## Prerequisites

You need your **tenant name**: the Keycloak realm identifier for your organisation. It is the organisation identifier in your portal URL:

```
https://portal.tidalcontrol.com/{your-tenant}/
```

For example, if your portal URL is `https://portal.tidalcontrol.com/demo/`, your tenant name is `demo`.

Throughout this guide, replace `{your-tenant}` with your actual tenant name.

## The authentication flow

The Device Authorization Grant works in three steps:

1. Your script requests a device code from Keycloak
2. You (or the script user) log in via a browser URL
3. Your script polls for the access token once login is complete

### Step 1: Request a device code

```bash
curl -X POST \
  "https://auth.tidalcontrol.com/realms/{your-tenant}/protocol/openid-connect/auth/device" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "client_id=portal&scope=openid"
```

Response:

```json
{
  "device_code": "...",
  "user_code": "ABCD-1234",
  "verification_uri": "https://auth.tidalcontrol.com/realms/{your-tenant}/device",
  "verification_uri_complete": "https://auth.tidalcontrol.com/realms/{your-tenant}/device?user_code=ABCD-1234",
  "expires_in": 600,
  "interval": 5
}
```

### Step 2: Log in via browser

Open `verification_uri_complete` in a browser and log in with your Tidal Control credentials. Your script waits while you do this.

### Step 3: Poll for the token

Poll the token endpoint every `interval` seconds until login completes:

```bash
curl -X POST \
  "https://auth.tidalcontrol.com/realms/{your-tenant}/protocol/openid-connect/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=urn:ietf:params:oauth:grant-type:device_code&client_id=portal&device_code={device_code}"
```

Once the user has logged in, the response contains your access token:

```json
{
  "access_token": "eyJhbGci...",
  "expires_in": 300,
  "refresh_token": "eyJhbGci...",
  "refresh_expires_in": 1800,
  "token_type": "Bearer"
}
```

> **info:**

Access tokens expire after **5 minutes**. Use the `refresh_token` to get a new access token without re-authenticating (see below).

## Making API requests

Include the access token in every GraphQL request as a `Bearer` header:

```bash
curl -X POST https://portal.tidalcontrol.com/graphql \
  -H "Authorization: Bearer {access_token}" \
  -H "Content-Type: application/json" \
  -d '{"query": "{ tenant { name displayName } }"}'
```

## Complete Python example

This script handles the full authentication flow and saves the token for reuse:

```python

TENANT = "{your-tenant}"
BASE_URL = f"https://auth.tidalcontrol.com/realms/{TENANT}/protocol/openid-connect"
CLIENT_ID = "portal"
GRAPHQL_URL = "https://portal.tidalcontrol.com/graphql"

def get_token():
    # Step 1: Request device code
    r = requests.post(
        f"{BASE_URL}/auth/device",
        data={"client_id": CLIENT_ID, "scope": "openid"},
    )
    r.raise_for_status()
    device = r.json()

    print(f"\nOpen this URL in your browser to log in:")
    print(f"  {device['verification_uri_complete']}\n")

    # Step 2: Poll for token
    interval = device.get("interval", 5)
    deadline = time.time() + device["expires_in"]

    while time.time() < deadline:
        time.sleep(interval)
        r = requests.post(
            f"{BASE_URL}/token",
            data={
                "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
                "client_id": CLIENT_ID,
                "device_code": device["device_code"],
            },
        )
        if r.status_code == 200:
            token = r.json()
            print("Authentication successful.")
            return token
        body = r.json()
        if body.get("error") == "authorization_pending":
            continue  # Still waiting for user to log in
        if body.get("error") == "slow_down":
            interval += 5
            continue
        r.raise_for_status()

    raise RuntimeError("Authentication timed out.")

def refresh_token(refresh_tok):
    r = requests.post(
        f"{BASE_URL}/token",
        data={
            "grant_type": "refresh_token",
            "client_id": CLIENT_ID,
            "refresh_token": refresh_tok,
        },
    )
    r.raise_for_status()
    return r.json()

def graphql(query, variables, access_token):
    r = requests.post(
        GRAPHQL_URL,
        json={"query": query, "variables": variables},
        headers={"Authorization": f"Bearer {access_token}"},
    )
    r.raise_for_status()
    result = r.json()
    if "errors" in result:
        raise RuntimeError(result["errors"])
    return result["data"]

if __name__ == "__main__":
    token = get_token()
    data = graphql("{ tenant { name displayName } }", {}, token["access_token"])
    print(json.dumps(data, indent=2))
```

## Refreshing an expired token

When the access token expires, use the refresh token instead of going through the full login flow again:

```python
token = refresh_token(token["refresh_token"])
access_token = token["access_token"]
```

> **tip:**

Refresh tokens also expire (typically after 30 minutes of inactivity). If a refresh fails, run the full `get_token()` flow again.

## Machine-to-machine authentication

For unattended integrations that run without a person present (a nightly export into a BI tool, a scheduled server-to-server sync), use a **service account** instead of the device flow. A service account is an OAuth 2.0 **Client Credentials** client: a `client_id` and `client_secret` pair that your script exchanges directly for an access token, with no browser step.

Service accounts are **read-only**. They can run queries but cannot run mutations.

> **info:**

Service accounts are enabled per organisation. If you don't see the **Service accounts** page (called **API Credentials** until October 2026) in your settings, email support@tidalcontrol.com to have it enabled.

### Creating a service account

Creating service accounts requires the **Super User** role.

1. Go to **Settings → Service accounts**.
2. Click **Create service account**, give it a label, and optionally set an expiry date.
3. Copy the **client secret** immediately. It is shown **once** and cannot be retrieved again. Store it in a secret manager or vault, never in plaintext config.

You now have a `client_id` and a `client_secret`. Together these are your service account's credentials.

### Requesting a token

Exchange the client ID and secret for an access token using the `client_credentials` grant. There is no browser step and no `scope` parameter:

```bash
curl -X POST \
  "https://auth.tidalcontrol.com/realms/{your-tenant}/protocol/openid-connect/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=client_credentials" \
  -d "client_id={client_id}" \
  -d "client_secret={client_secret}"
```

Response:

```json
{
  "access_token": "eyJhbGci...",
  "expires_in": 300,
  "token_type": "Bearer"
}
```

> **info:**

This flow has **no refresh token**. When the access token expires (after about 5 minutes), request a new one with the same `client_credentials` call.

Include the access token in every GraphQL request as a `Bearer` header, exactly as in **Making API requests** above.

### Complete shell example

Set `CLIENT_SECRET` to the secret you copied, then fetch a token and call the API:

```bash
export CLIENT_SECRET='<the secret you copied>'

ACCESS_TOKEN=$(curl -s -X POST \
  "https://auth.tidalcontrol.com/realms/{your-tenant}/protocol/openid-connect/token" \
  -d "grant_type=client_credentials" \
  -d "client_id={client_id}" \
  -d "client_secret=$CLIENT_SECRET" \
  | jq -r .access_token)

curl -s https://portal.tidalcontrol.com/graphql \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"query": "{ currentTenant { name } }"}'
```

### What a service account can read

A read-only service account can read your organisation's **entire data set**: controls, evidence, personnel, KPIs, reports, and the user directory (names and email addresses). Treat a leaked secret as full-organisation data exposure and rotate it immediately.

### Rotating and revoking

From **Settings → Service accounts**, use the row actions (kebab menu, three vertical dots) to:

- **Rotate secret**: generate a new secret and invalidate the old one. Update your integration with the new secret.
- **Revoke**: disable the service account permanently.

> **tip:**

Access tokens stay valid until they expire (about 5 minutes), so a rotate, revoke, or expiry takes effect within one token lifetime. Allow for up to a 5-minute window before the change is fully enforced.
