> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tiro.ooo/llms.txt
> Use this file to discover all available pages before exploring further.

# Build consent-based integrations with OAuth

> An OAuth 2.0 integration that lets your internal systems reach a member's Tiro notes after a single consent, with no API key to issue.

With an OAuth app, your internal portal can read a member's notes on their behalf without each member issuing an API key. The member signs in to Tiro and passes a consent screen once, and the app then acts only within the permissions that member already has.

## When should you use an OAuth app?

| Situation                                                                             | Recommended approach                                                |
| ------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
| An internal portal or collaboration tool shows the notes of **the signed-in member**  | **OAuth app** (this page)                                           |
| A batch job or provisioning script handles organization data **with no user present** | [Organization API key](/en/developers/organization/org-integration) |
| An individual works with their own notes in their own tooling                         | [Account API key](/en/developers/fundamentals/authentication)       |

OAuth apps support the Authorization Code flow only, and PKCE is required. There is no `client_credentials` flow for obtaining a token without a user, so use an API key for server-to-server integrations.

## Step 1. Register an OAuth app

An organization **Admin** or **Developer** registers the app at [Tiro Platform](https://platform.tiro.ooo). OAuth apps are available in the organization scope only.

<Steps>
  <Step title="Open the OAuth apps screen">
    In the Tiro Platform side menu, open your organization's **\[OAuth apps]** menu and press **\[Register OAuth app]**.
  </Step>

  <Step title="Enter the app details">
    Enter the app name, the **Redirect URI**, and the **scopes** the app will request from users. The Redirect URI must start with `https://`, and it won't register if the URL carries user info or a fragment after `#`. It has to match the URL you return to after authorization character for character. It can't be edited after registration, so register a new app if the URL changes.
  </Step>

  <Step title="Store the Client ID and Client Secret">
    Right after registration, the screen shows the **Client ID** and **Client Secret**. The Client Secret is shown this one time and can't be retrieved again. Store it somewhere safe, such as a server environment variable, right away.
  </Step>
</Steps>

`http://localhost` can't be registered as a Redirect URI. For local development, register an HTTPS tunnel or a development domain as the Redirect URI and let that address forward the callback to your machine.

<Note>
  The OAuth app registration screen is rolling out gradually. If you don't see the menu, ask your account manager or [partners@theplato.io](mailto:partners@theplato.io) and we'll issue an app for you.
</Note>

<Warning>
  Once you close the registration screen, the Client Secret can't be viewed again. If you lose it, get a new one from the refresh icon at the right of that app's row in the OAuth apps list. The current secret stops working the moment you regenerate, so be ready to update the servers that use the app. Discarding an app also disconnects every user connection made through it.
</Warning>

### Scopes you can request

An OAuth app can request only the permissions a user is able to delegate. Organization management scopes (`organization_member:*`, `workspace:*`, `session:write`) can't be delegated and are available to organization API keys only.

| Scope                         | What the app can do                                        |
| ----------------------------- | ---------------------------------------------------------- |
| `note:read`                   | Read note lists, metadata, and transcripts                 |
| `note:write`                  | Update note metadata such as titles. Includes `note:read`  |
| `note_summary:read`           | Read note summaries (one-page documents)                   |
| `note_document:read`          | Read generated documents                                   |
| `note_document_template:read` | List and read document templates                           |
| `folder:read`                 | Read folders                                               |
| `folder:write`                | Create, update, and delete folders. Includes `folder:read` |
| `wiki:read`                   | Read and search Wiki                                       |
| `word_memory:read`            | Read word memory                                           |

What the app actually reaches is the overlap between the scopes the user consented to and the permissions that user holds in Tiro. Workspaces and notes the user can't see stay out of reach no matter which scopes were granted.

## Step 2. Get user consent

Send the user from your app to the Tiro authorization page. First generate a random `code_verifier`, then hash that string as UTF-8 bytes with SHA-256 and send the unpadded Base64URL encoding of the digest as `code_challenge`. Sending a hex string or the raw digest bytes gets rejected at the token exchange step.

```text theme={"system"}
GET https://api.tiro.ooo/v1/oauth/authorize
  ?response_type=code
  &client_id=YOUR_CLIENT_ID
  &redirect_uri=https://your-app.example.com/tiro/callback
  &scope=note:read%20note_summary:read
  &state=RANDOM_STATE
  &code_challenge=CODE_CHALLENGE
  &code_challenge_method=S256
  &resource=https://api.tiro.ooo
```

| Parameter                                 | Description                                                                                                                         |
| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `response_type`                           | Always `code`                                                                                                                       |
| `client_id`                               | The Client ID of the registered app                                                                                                 |
| `redirect_uri`                            | Exactly the same value as the registered Redirect URI                                                                               |
| `scope`                                   | A space-separated list of scopes. You can request only a subset of the scopes registered on the app; omit it to request all of them |
| `state`                                   | An unpredictable random string. The same value comes back on the callback                                                           |
| `code_challenge`, `code_challenge_method` | PKCE values. Only `S256` is supported                                                                                               |
| `resource`                                | The target the token is used against. For the External API this is `https://api.tiro.ooo`                                           |

After the user signs in with their Tiro account, reviews the app name and requested scopes on the consent screen, and approves, `code` and `state` come back to your Redirect URI.

```text theme={"system"}
https://your-app.example.com/tiro/callback?code=AUTHORIZATION_CODE&state=RANDOM_STATE
```

### Validate state on the callback

Check `state` before you exchange the token. Store the value you generated for the authorize request in the user's session, compare it with the value that comes back on the callback, then clear the stored value. If it's missing or different, stop the request right there.

Skipping this check allows login CSRF: an authorization the attacker started gets attached to the victim's session, connecting them to an account they know nothing about.

<Note>
  Members of organizations using SSO sign in through their corporate IdP as usual before the consent screen. The consent screen is scoped to that member's Tiro account, and the app receives that account's permissions only.
</Note>

## Step 3. Exchange the code for a token

Turn the validated `code` into an access token from your server. The examples below read the authorization code from the callback and the `code_verifier` from step 2 as environment variables. Handle the Client Secret on the server only and never put it in browser or app code. An authorization code works exactly once, so sending it twice is rejected with `invalid_grant`.

<CodeGroup>
  ```bash cURL theme={"system"}
  curl -X POST https://api.tiro.ooo/v1/oauth/token \
    -u "$TIRO_CLIENT_ID:$TIRO_CLIENT_SECRET" \
    -d grant_type=authorization_code \
    -d code="$AUTHORIZATION_CODE" \
    -d redirect_uri="https://your-app.example.com/tiro/callback" \
    -d code_verifier="$CODE_VERIFIER" \
    -d resource="https://api.tiro.ooo"
  ```

  ```javascript Node.js theme={"system"}
  const authorizationCode = process.env.AUTHORIZATION_CODE;
  const codeVerifier = process.env.CODE_VERIFIER;
  const credentials = Buffer.from(
    process.env.TIRO_CLIENT_ID + ":" + process.env.TIRO_CLIENT_SECRET
  ).toString("base64");

  const response = await fetch("https://api.tiro.ooo/v1/oauth/token", {
    method: "POST",
    headers: {
      Authorization: "Basic " + credentials,
      "Content-Type": "application/x-www-form-urlencoded",
    },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code: authorizationCode,
      redirect_uri: "https://your-app.example.com/tiro/callback",
      code_verifier: codeVerifier,
      resource: "https://api.tiro.ooo",
    }),
  });

  const token = await response.json();
  ```

  ```python Python theme={"system"}
  import os
  import requests

  authorization_code = os.environ["AUTHORIZATION_CODE"]
  code_verifier = os.environ["CODE_VERIFIER"]

  response = requests.post(
      "https://api.tiro.ooo/v1/oauth/token",
      auth=(os.environ["TIRO_CLIENT_ID"], os.environ["TIRO_CLIENT_SECRET"]),
      data={
          "grant_type": "authorization_code",
          "code": authorization_code,
          "redirect_uri": "https://your-app.example.com/tiro/callback",
          "code_verifier": code_verifier,
          "resource": "https://api.tiro.ooo",
      },
  )

  token = response.json()
  ```

  ```go Go theme={"system"}
  authorizationCode := os.Getenv("AUTHORIZATION_CODE")
  codeVerifier := os.Getenv("CODE_VERIFIER")

  form := url.Values{}
  form.Set("grant_type", "authorization_code")
  form.Set("code", authorizationCode)
  form.Set("redirect_uri", "https://your-app.example.com/tiro/callback")
  form.Set("code_verifier", codeVerifier)
  form.Set("resource", "https://api.tiro.ooo")

  req, err := http.NewRequest(
      "POST",
      "https://api.tiro.ooo/v1/oauth/token",
      strings.NewReader(form.Encode()),
  )
  if err != nil {
      return err
  }
  req.SetBasicAuth(os.Getenv("TIRO_CLIENT_ID"), os.Getenv("TIRO_CLIENT_SECRET"))
  req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

  resp, err := http.DefaultClient.Do(req)
  ```

  ```kotlin Kotlin + Spring theme={"system"}
  val authorizationCode = System.getenv("AUTHORIZATION_CODE")
  val codeVerifier = System.getenv("CODE_VERIFIER")
  val clientId = System.getenv("TIRO_CLIENT_ID")
  val clientSecret = System.getenv("TIRO_CLIENT_SECRET")

  val form = LinkedMultiValueMap<String, String>()
  form.add("grant_type", "authorization_code")
  form.add("code", authorizationCode)
  form.add("redirect_uri", "https://your-app.example.com/tiro/callback")
  form.add("code_verifier", codeVerifier)
  form.add("resource", "https://api.tiro.ooo")

  val token = RestClient.create()
      .post()
      .uri("https://api.tiro.ooo/v1/oauth/token")
      .headers { headers -> headers.setBasicAuth(clientId, clientSecret) }
      .body(form)
      .retrieve()
      .body(TokenResponse::class.java)
  ```
</CodeGroup>

```json theme={"system"}
{
  "access_token": "eyJhbGciOi...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "note:read note_summary:read"
}
```

Client authentication works with both HTTP Basic and body parameters (`client_id`, `client_secret`).

<Warning>
  `expires_in` is the remaining lifetime in seconds and varies with the app's configuration. Don't hardcode the value; use whatever comes back in the response. Apps that also receive a `refresh_token` should follow the refresh procedure below, and apps that don't can send the user back to the authorization page before the token expires.
</Warning>

## Step 4. Call the API

Put the access token in the `Authorization` header exactly as you would an API key. The endpoints you can call and the response formats match what's covered in the [API Overview](/en/developers/fundamentals/api-overview).

```bash theme={"system"}
curl https://api.tiro.ooo/v1/external/notes \
  -H "Authorization: Bearer $ACCESS_TOKEN"
```

The notes in the response are the ones the consenting user can see in the app. It's the same result as calling with that user's account API key.

## Step 5. Refresh the token

If the token exchange response includes a `refresh_token`, you can get a new access token without asking the user to consent again.

<CodeGroup>
  ```bash cURL theme={"system"}
  curl -X POST https://api.tiro.ooo/v1/oauth/token \
    -u "$TIRO_CLIENT_ID:$TIRO_CLIENT_SECRET" \
    -d grant_type=refresh_token \
    -d refresh_token="$REFRESH_TOKEN"
  ```

  ```javascript Node.js theme={"system"}
  const response = await fetch("https://api.tiro.ooo/v1/oauth/token", {
    method: "POST",
    headers: {
      Authorization: "Basic " + credentials,
      "Content-Type": "application/x-www-form-urlencoded",
    },
    body: new URLSearchParams({
      grant_type: "refresh_token",
      refresh_token: process.env.REFRESH_TOKEN,
    }),
  });

  const token = await response.json();
  ```

  ```python Python theme={"system"}
  response = requests.post(
      "https://api.tiro.ooo/v1/oauth/token",
      auth=(os.environ["TIRO_CLIENT_ID"], os.environ["TIRO_CLIENT_SECRET"]),
      data={
          "grant_type": "refresh_token",
          "refresh_token": os.environ["REFRESH_TOKEN"],
      },
  )

  token = response.json()
  ```
</CodeGroup>

* A refresh token works exactly once. When the response carries a new refresh token, discard the previous value and store the new one.
* Sending a refresh token that was already used is treated as theft, and every token on that user connection is invalidated. You then have to ask the user to consent again.
* A connection left unrefreshed for a long time expires. Each refresh pushes the expiry further out, so connections in steady use stay alive.
* Refreshes are rejected once the app has been discarded or the user has disconnected it.

<Tip>
  If a network problem leaves you without a response to a refresh request, don't send the same refresh token again. The server may have already rotated it. Clearing the stored token and prompting the user to reauthorize is the safer move.
</Tip>

## Disconnect

When a user stops using the app, revoke the refresh token to disconnect. Access tokens already issued expire on their own once `expires_in` passes.

<CodeGroup>
  ```bash cURL theme={"system"}
  curl -X POST https://api.tiro.ooo/v1/oauth/revoke \
    -u "$TIRO_CLIENT_ID:$TIRO_CLIENT_SECRET" \
    -d token="$REFRESH_TOKEN"
  ```

  ```javascript Node.js theme={"system"}
  await fetch("https://api.tiro.ooo/v1/oauth/revoke", {
    method: "POST",
    headers: {
      Authorization: "Basic " + credentials,
      "Content-Type": "application/x-www-form-urlencoded",
    },
    body: new URLSearchParams({ token: process.env.REFRESH_TOKEN }),
  });
  ```

  ```python Python theme={"system"}
  requests.post(
      "https://api.tiro.ooo/v1/oauth/revoke",
      auth=(os.environ["TIRO_CLIENT_ID"], os.environ["TIRO_CLIENT_SECRET"]),
      data={"token": os.environ["REFRESH_TOKEN"]},
  )
  ```
</CodeGroup>

## Server metadata

The endpoints and supported methods are published in the standard metadata document.

```text theme={"system"}
GET https://api.tiro.ooo/.well-known/oauth-authorization-server
```

## Common errors

| Error                                        | Cause and fix                                                                                                                                                                     |
| -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| authorize returns 400                        | The Redirect URI differs from the registered value. A trailing slash or a query string counts as a mismatch.                                                                      |
| Token exchange returns `invalid_grant`       | The authorization code was already used or has expired. Send the user back to the authorization page. The same error appears when `code_verifier` doesn't match `code_challenge`. |
| Token exchange returns `invalid_client`      | The Client ID or Secret is wrong. A discarded app gives the same result.                                                                                                          |
| authorize returns `invalid_scope`            | You requested a scope that isn't registered on the app. Only a subset of the registered scopes can be requested.                                                                  |
| An API call returns `403 insufficient_scope` | The consented scopes don't cover this API. Check which scope it needs in [Authentication](/en/developers/fundamentals/authentication).                                            |

***

**Related pages**: [Authentication](/en/developers/fundamentals/authentication) · [Integrate at the organization level](/en/developers/organization/org-integration) · [API Overview](/en/developers/fundamentals/api-overview)
