> ## 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.

# OAuth로 사용자 동의 기반 연동하기

> 구성원이 API Key를 발급하지 않아도, 동의 한 번으로 사내 시스템이 그 구성원의 티로 노트에 접근하는 OAuth 2.0 연동 방법.

OAuth 앱을 쓰면 구성원이 각자 API Key를 발급하지 않아도 사내 포털이 그 구성원의 노트를 대신 조회할 수 있어요. 구성원은 티로 로그인과 동의 화면을 한 번 거치고, 앱은 그 사용자가 가진 권한 안에서만 동작해요.

## 언제 OAuth 앱을 쓰나요?

| 상황                                         | 권장 방식                                                     |
| ------------------------------------------ | --------------------------------------------------------- |
| 사내 포털이나 협업 도구에서 **로그인한 구성원 본인**의 노트를 보여줘요  | **OAuth 앱** (이 문서)                                        |
| 배치 작업이나 프로비저닝 스크립트가 **사용자 없이** 조직 데이터를 다뤄요 | [조직 API Key](/ko/developers/organization/org-integration) |
| 개인이 자기 도구에서 자기 노트를 다뤄요                     | [계정 API Key](/ko/developers/fundamentals/authentication)  |

OAuth 앱은 Authorization Code 방식만 지원하고 PKCE가 필수예요. 사용자 없이 토큰을 받는 `client_credentials` 방식은 제공하지 않으니, 서버 간 연동에는 API Key를 쓰세요.

## 1단계. OAuth 앱 등록하기

조직 **Admin** 또는 **Developer** 가 [Tiro Platform](https://platform.tiro.ooo)에서 등록해요. OAuth 앱은 조직 범위에서만 등록할 수 있어요.

<Steps>
  <Step title="OAuth 앱 화면 열기">
    Tiro Platform 사이드 메뉴에서 조직의 **\[OAuth 앱]** 메뉴를 열고 **\[OAuth 앱 등록]** 버튼을 눌러요.
  </Step>

  <Step title="앱 정보 입력">
    앱 이름, **Redirect URI**, 사용자에게 요청할 **권한** 을 입력해요. Redirect URI는 `https://` 로 시작해야 하고, 주소에 사용자 정보나 `#` 뒤 프래그먼트가 붙어 있으면 등록되지 않아요. 인증 후 돌아갈 주소와 문자 하나까지 같아야 해요. 등록한 뒤에는 바꿀 수 없으니 주소가 바뀌면 앱을 새로 등록하세요.
  </Step>

  <Step title="Client ID와 Client Secret 저장">
    등록 직후 화면에 **Client ID** 와 **Client Secret** 이 표시돼요. Client Secret은 이때 한 번만 보여주고 다시 확인할 수 없어요. 서버 환경 변수처럼 안전한 곳에 바로 저장하세요.
  </Step>
</Steps>

`http://localhost` 는 Redirect URI로 등록할 수 없어요. 로컬에서 개발할 때는 HTTPS 터널이나 개발용 도메인을 Redirect URI로 등록하고, 그 주소가 콜백을 로컬로 넘겨주도록 두세요.

<Note>
  OAuth 앱 등록 화면은 순차적으로 공개하고 있어요. 메뉴가 보이지 않으면 도입 담당 매니저나 [partners@theplato.io](mailto:partners@theplato.io)로 요청하면 앱을 발급해 드려요.
</Note>

<Warning>
  Client Secret은 등록 화면을 닫으면 다시 볼 수 없어요. 잃어버렸다면 OAuth 앱 목록에서 그 앱 행 오른쪽의 새로고침 아이콘을 눌러 새 값을 받으세요. 재발급하는 순간 이전 Secret은 쓸 수 없으니, 앱을 쓰는 서버의 설정을 바꿀 준비를 하고 진행하세요. 앱을 폐기하면 그 앱으로 맺은 사용자 연결도 함께 끊겨요.
</Warning>

### 요청할 수 있는 권한

OAuth 앱은 사용자가 위임할 수 있는 권한만 요청해요. 조직 관리용 권한(`organization_member:*`, `workspace:*`, `session:write`)은 위임할 수 없고 조직 API Key로만 쓸 수 있어요.

| Scope                         | 앱이 할 수 있는 일                       |
| ----------------------------- | --------------------------------- |
| `note:read`                   | 노트 목록, 메타데이터, 대화 기록 조회            |
| `note:write`                  | 노트 제목 같은 메타데이터 수정. `note:read` 포함 |
| `note_summary:read`           | 노트 요약(한 페이지 문서) 조회                |
| `note_document:read`          | 생성된 문서 조회                         |
| `note_document_template:read` | 문서 템플릿 목록과 내용 조회                  |
| `folder:read`                 | 폴더 조회                             |
| `folder:write`                | 폴더 생성, 수정, 삭제. `folder:read` 포함   |
| `wiki:read`                   | 위키 조회와 검색                         |
| `word_memory:read`            | 단어장 조회                            |

앱이 실제로 닿는 범위는 사용자가 동의한 권한과 그 사용자가 티로에서 가진 권한이 겹치는 부분이에요. 사용자가 볼 수 없는 워크스페이스나 노트는 권한을 동의받아도 조회할 수 없어요.

## 2단계. 사용자 동의 받기

앱에서 사용자를 티로 인증 페이지로 보내요. 먼저 임의의 `code_verifier` 를 만들고, 그 문자열을 UTF-8 바이트로 SHA-256 해시한 뒤 패딩 없는 Base64URL로 인코딩한 값을 `code_challenge` 로 보내요. 16진수 문자열이나 해시 원본 바이트를 그대로 보내면 토큰 교환 단계에서 거절돼요.

```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
```

| 파라미터                                      | 설명                                                              |
| ----------------------------------------- | --------------------------------------------------------------- |
| `response_type`                           | 항상 `code`                                                       |
| `client_id`                               | 등록한 앱의 Client ID                                                |
| `redirect_uri`                            | 등록한 Redirect URI와 정확히 같은 값                                      |
| `scope`                                   | 공백으로 구분한 권한 목록. 앱에 등록한 권한의 부분집합만 요청할 수 있고, 생략하면 등록한 권한 전체를 요청해요 |
| `state`                                   | 예측할 수 없는 임의 문자열. 콜백에 같은 값이 돌아와요                                 |
| `code_challenge`, `code_challenge_method` | PKCE 값. 방식은 `S256` 만 지원해요                                       |
| `resource`                                | 토큰을 사용할 대상. External API는 `https://api.tiro.ooo`                |

사용자가 티로 계정으로 로그인하고 동의 화면에서 앱 이름과 요청 권한을 확인한 뒤 승인하면, Redirect URI로 `code` 와 `state` 가 돌아와요.

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

### 콜백에서 state를 검증하세요

토큰을 교환하기 전에 `state` 부터 확인해요. authorize 요청 때 만든 값을 사용자 세션에 저장해 두고, 콜백으로 돌아온 값과 같은지 비교한 뒤 저장한 값을 지워요. 값이 없거나 다르면 그 자리에서 요청을 중단해요.

이 검증을 건너뛰면 공격자가 시작한 인증 결과가 피해자 세션에 붙어, 피해자가 모르는 계정으로 연결되는 로그인 CSRF가 가능해요.

<Note>
  SSO를 쓰는 조직의 구성원은 동의 화면 전에 평소처럼 사내 IdP로 로그인해요. 동의 화면은 그 구성원의 티로 계정 기준으로 뜨고, 앱도 그 계정의 권한만 넘겨받아요.
</Note>

## 3단계. 토큰 교환하기

검증을 마친 `code` 를 서버에서 액세스 토큰으로 바꿔요. 아래 예제는 콜백에서 받은 인증 코드와 2단계에서 만든 `code_verifier` 를 환경 변수로 읽어요. Client Secret은 서버에서만 다루고 브라우저나 앱 코드에 넣지 마세요. 인증 코드는 한 번만 쓸 수 있어서 두 번 보내면 `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"
}
```

클라이언트 인증은 HTTP Basic과 본문 파라미터(`client_id`, `client_secret`) 둘 다 지원해요.

<Warning>
  `expires_in` 은 초 단위 남은 시간이고 앱 설정에 따라 달라요. 값을 코드에 고정하지 말고 응답에 온 값을 그대로 쓰세요. 응답에 `refresh_token` 이 함께 오는 앱은 아래 갱신 절차를 따르고, 오지 않는 앱은 만료 전에 사용자를 다시 인증 페이지로 보내면 돼요.
</Warning>

## 4단계. API 호출하기

액세스 토큰은 API Key와 같은 방식으로 `Authorization` 헤더에 넣어요. 호출할 수 있는 엔드포인트와 응답 형식은 [API 개요](/ko/developers/fundamentals/api-overview)에 정리한 것과 같아요.

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

응답에 담기는 노트는 동의한 사용자가 앱에서 볼 수 있는 범위예요. 그 사용자의 계정 API Key로 호출했을 때와 같아요.

## 5단계. 토큰 갱신하기

토큰 교환 응답에 `refresh_token` 이 있으면, 사용자에게 다시 동의를 받지 않고 새 액세스 토큰을 받을 수 있어요.

<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>

* refresh 토큰은 한 번만 쓸 수 있어요. 응답에 새 refresh 토큰이 오면 이전 값은 버리고 새 값을 저장하세요.
* 이미 쓴 refresh 토큰을 다시 보내면 탈취로 판단해서 그 사용자 연결의 토큰을 전부 무효화해요. 이때는 사용자에게 다시 동의를 받아야 해요.
* 오랫동안 갱신하지 않은 연결은 만료돼요. 갱신할 때마다 만료 시점이 뒤로 밀려서, 꾸준히 쓰는 연결은 끊기지 않아요.
* 앱을 폐기했거나 사용자가 연결을 끊었으면 갱신은 거절돼요.

<Tip>
  갱신 요청을 보낸 뒤 네트워크 문제로 응답을 받지 못했다면 같은 refresh 토큰을 다시 보내지 마세요. 서버에서는 이미 교체가 끝났을 수 있어요. 저장한 토큰을 지우고 사용자에게 재인증을 안내하는 편이 안전해요.
</Tip>

## 연결 끊기

사용자가 앱 사용을 그만두면 refresh 토큰을 폐기해 연결을 끊어요. 이미 발급한 액세스 토큰은 `expires_in` 이 지나면 자연히 만료돼요.

<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>

## 서버 메타데이터

엔드포인트와 지원 방식은 표준 메타데이터 문서에서 확인할 수 있어요.

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

## 자주 묻는 오류

| 오류                               | 원인과 해결                                                                                              |
| -------------------------------- | --------------------------------------------------------------------------------------------------- |
| authorize 요청이 400                | Redirect URI가 등록한 값과 달라요. 끝의 슬래시나 쿼리 문자열 차이도 불일치로 봐요.                                               |
| 토큰 교환이 `invalid_grant`           | 인증 코드를 이미 썼거나 만료됐어요. 사용자를 다시 인증 페이지로 보내세요. `code_verifier` 가 `code_challenge` 와 맞지 않을 때도 같은 오류가 나요. |
| 토큰 교환이 `invalid_client`          | Client ID나 Secret이 틀렸어요. 앱을 폐기한 경우도 같아요.                                                            |
| authorize가 `invalid_scope`       | 앱에 등록하지 않은 권한을 요청했어요. 등록한 권한의 부분집합만 요청할 수 있어요.                                                      |
| API 호출이 `403 insufficient_scope` | 동의받은 권한으로는 호출할 수 없는 API예요. 필요한 권한은 [인증](/ko/developers/fundamentals/authentication)에서 확인하세요.        |

***

**관련 페이지**: [인증](/ko/developers/fundamentals/authentication), [조직 단위 연동](/ko/developers/organization/org-integration), [API 개요](/ko/developers/fundamentals/api-overview)
