# OAuth와 개발자 앱

> client.developerApps로 Nodaro OAuth 앱을 등록하고 관리하며, client.oauth로 코드를 교환하고 토큰을 폐기하고 동의 화면 데이터를 읽습니다.

Source: https://nodaro.ai/ko/docs/developers/sdk/developer-apps

<strong>`client.developerApps`</strong>는 내가 소유한 OAuth 앱을 관리합니다. OAuth 앱은 다른 Nodaro 사용자가 내 소프트웨어에 자신의 계정 접근 권한을 줄 수 있게 하는 앱입니다. <strong>`client.oauth`</strong>는 OAuth 2.0 흐름의 서버 쪽을 담당합니다. 인증 코드를 액세스 토큰으로 교환하고, 토큰을 폐기하고, 동의 화면에 표시할 공개 데이터를 반환합니다. 전체 동의 흐름은 [OAuth](https://nodaro.ai/docs/developers/oauth)에 설명되어 있습니다.

## 메서드
| 메서드 | 기능 |
| --- | --- |
| [`developerApps.list()`](#developerappslist) | 내 앱 목록 |
| [`developerApps.get(id)`](#developerappsgetid) | 앱 하나 읽기 |
| [`developerApps.create(input)`](#developerappscreateinput) | 앱을 등록하고 시크릿 받기 |
| [`developerApps.update(id, input)`](#developerappsupdateid-input) | 앱 변경 |
| [`developerApps.delete(id)`](#developerappsdeleteid) | 앱 삭제 |
| [`developerApps.rotateSecret(id)`](#developerappsrotatesecretid) | 앱의 시크릿 교체 |
| [`oauth.exchangeCode(input)`](#oauthexchangecodeinput) | 인증 코드를 액세스 토큰으로 교환 |
| [`oauth.revoke(token)`](#oauthrevoketoken) | 액세스 토큰 폐기 |
| [`oauth.getAppInfo(clientId, redirectUri?)`](#oauthgetappinfoclientid-redirecturi) | 동의 화면에 쓸 앱의 공개 데이터 읽기 |

## 스코프
앱은 스코프를 요청하고, 사용자는 그 스코프를 승인합니다. 서버는 다음 스코프를 받습니다.

| 스코프 | 허용하는 작업 |
| --- | --- |
| `workflows:read` | 워크플로 읽기 |
| `workflows:write` | 워크플로 만들기와 변경 |
| `workflows:execute` | 워크플로 실행 |
| `jobs:read` | 작업 읽기 |
| `assets:read` | 캐릭터, 장소, 오브젝트 등 에셋 읽기 |
| `assets:write` | 에셋 만들기와 변경 |
| `credits:read` | 크레딧 잔액 읽기 |
| `apps:read` | 게시된 앱 읽기 |
| `pipelines:read` | 파이프라인 읽기 |
| `pipelines:execute` | 파이프라인 시작과 취소 |
| `pipelines:approve` | 파이프라인 단계 승인과 거부 |
| `presets:read` | 노드 프리셋 읽기 |
| `workspaces:read` | 사용자가 속한 워크스페이스 나열 |
| `workspaces:write` | 앱이 동작할 워크스페이스 선택 |

각 스코프에 대해 동의 화면에 표시되는 내용은 [OAuth 앱](https://nodaro.ai/docs/developers/oauth#scopes)에 나와 있습니다. 필요한 스코프 없이 호출하면 `ForbiddenError`가 발생하며, 그 `missingScope`에 빠진 스코프가 담깁니다. [스코프와 누락된 권한](https://nodaro.ai/docs/developers/sdk/auth#scopes-and-missing-permissions)을 참조하세요.

## client.developerApps
앱은 소유자만 읽거나 변경할 수 있습니다. 시크릿은 딱 한 번만 반환됩니다.

### developerApps.list()
내 앱을 나열합니다.

```ts
list(): Promise<{ data: DeveloperApp[] }>
```

```ts
const { data: apps } = await client.developerApps.list()
const mine = apps.filter((app) => (app.kind ?? "user") === "user")
```

`DeveloperApp`에는 `id`, `name`, `description`, `logoUrl`, `homepageUrl`, `redirectUris`, `allowedOrigins`, `scopesRequested`, `clientId`, `status`(`active`, `suspended`, `pending_review` 중 하나), `kind`, `createdAt`, `updatedAt`이 있습니다. 직접 등록한 앱의 `kind`는 `"user"`입니다. 나머지 값인 `dynamic_mcp`, `first_party_mcp`, `community_instance`는 스스로 등록한 클라이언트입니다. 5개 한도에는 `"user"` 앱만 포함됩니다.

### developerApps.get(id)
앱 하나를 읽습니다. 시크릿은 절대 포함되지 않습니다.

```ts
get(id: string): Promise<{ data: DeveloperApp }>
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "앱 ID입니다." },
}}
/>

```ts
const { data: app } = await client.developerApps.get(appId)
```

### developerApps.create(input)
앱을 등록합니다. 응답에 `clientSecret`이 들어 있습니다. 서버는 그 해시만 보관하므로 지금 저장하세요.

```ts
create(input: CreateDeveloperAppInput): Promise<{ data: DeveloperApp & { clientSecret: string } }>
```

<TypeTable
type={{
name: { type: 'string', required: true, description: "사용자가 동의 화면에서 보는 앱 이름입니다." },
redirectUris: { type: 'string[]', required: true, description: "URI 1~10개이며, 각각 https:// 또는 http://localhost여야 합니다." },
scopesRequested: { type: 'DeveloperAppScope[]', required: true, description: "스코프 1개 이상입니다." },
allowedOrigins: { type: 'string[]', description: "이 앱의 토큰으로 브라우저에서 API를 호출할 수 있는 출처이며, 최대 5개입니다. 경로, 쿼리, 해시 없이 출처만 적습니다." },
description: { type: 'string', description: "동의 화면에 표시할 설명입니다." },
homepageUrl: { type: 'string', description: "내 앱의 홈페이지입니다." },
logoUrl: { type: 'string', description: "내 앱의 로고입니다." },
}}
/>

```ts
const { data } = await client.developerApps.create({
name: "My integration",
redirectUris: ["https://example.com/oauth/callback"],
scopesRequested: ["workflows:read", "workflows:execute"],
})
console.log(data.clientId, data.clientSecret) // save both now
```

앱은 5개까지 등록할 수 있습니다. 6번째는 `400 limit_reached`로 실패합니다.

### developerApps.update(id, input)
앱을 변경합니다. 바꿀 필드만 전달하세요. 각 필드에는 `create()`의 규칙이 적용됩니다.

```ts
update(id: string, input: UpdateDeveloperAppInput): Promise<{ data: DeveloperApp }>
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "앱 ID입니다." },
name: { type: 'string', description: "앱 이름입니다." },
description: { type: 'string', description: "설명입니다." },
homepageUrl: { type: 'string', description: "홈페이지입니다." },
logoUrl: { type: 'string', description: "로고입니다." },
redirectUris: { type: 'string[]', description: "리디렉션 URI의 새 전체 목록입니다." },
allowedOrigins: { type: 'string[]', description: "브라우저 출처의 새 전체 목록입니다." },
scopesRequested: { type: 'DeveloperAppScope[]', description: "스코프의 새 전체 목록입니다." },
}}
/>

```ts
await client.developerApps.update(appId, {
redirectUris: ["https://example.com/oauth/callback", "https://staging.example.com/oauth/callback"],
})
```

### developerApps.delete(id)
앱을 삭제합니다.

```ts
delete(id: string): Promise<{ success: true }>
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "앱 ID입니다." },
}}
/>

```ts
await client.developerApps.delete(appId)
```

ID가 없거나 내 앱이 아니면 `NotFoundError`가 발생합니다.

### developerApps.rotateSecret(id)
새 클라이언트 시크릿을 만들고 이전 시크릿을 즉시 무효화합니다. 새 시크릿은 한 번만 반환됩니다.

```ts
rotateSecret(id: string): Promise<{ clientSecret: string }>
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "앱 ID입니다." },
}}
/>

```ts
const { clientSecret } = await client.developerApps.rotateSecret(appId)
```

이전 시크릿으로는 코드 교환이 더 이상 동작하지 않으므로, 서버의 시크릿을 바로 업데이트하세요.

## client.oauth
내 앱의 서버가 호출하는 OAuth 2.0 엔드포인트입니다. 필드 이름은 OAuth 표준처럼 snake_case를 사용합니다.

### oauth.exchangeCode(input)
동의 후 리디렉션으로 받은 인증 코드를 액세스 토큰으로 교환합니다(`POST /v1/oauth/token`). SDK가 `grant_type: "authorization_code"`를 자동으로 추가합니다.

**브라우저에서는 절대 호출하지 마세요.** 요청에 클라이언트 시크릿이 들어 있으며, 시크릿은 내 서버에만 있어야 합니다.

```ts
exchangeCode(input: {
client_id: string
client_secret: string
code: string
redirect_uri: string
}): Promise<{ access_token: string; token_type: "Bearer"; scope: string; expires_in: number }>
```

<TypeTable
type={{
client_id: { type: 'string', required: true, description: "내 앱의 클라이언트 ID입니다." },
client_secret: { type: 'string', required: true, description: "내 앱의 클라이언트 시크릿입니다." },
code: { type: 'string', required: true, description: "동의 후 리디렉션으로 받은 코드입니다." },
redirect_uri: { type: 'string', required: true, description: "코드를 받을 때 사용한 것과 같은 리디렉션 URI입니다." },
}}
/>

```ts

const tokens = await client.oauth.exchangeCode({
client_id: process.env.NODARO_CLIENT_ID!,
client_secret: process.env.NODARO_CLIENT_SECRET!,
code: req.query.code as string,
redirect_uri: "https://example.com/oauth/callback",
})

// Act for the user who approved your app
const userClient = createClient({
baseUrl: "https://app.nodaro.ai",
auth: new StaticTokenAuth(tokens.access_token),
})
```

`scope`는 승인된 스코프를 공백으로 구분해 나열하며, `expires_in`은 토큰의 유효 기간(초)입니다.

### oauth.revoke(token)
액세스 토큰을 폐기합니다(`POST /v1/oauth/revoke`, RFC 7009). 표준이 토큰의 유효 여부를 드러내지 못하게 하므로, 알 수 없는 토큰에도 항상 `{ success: true }`로 응답합니다.

```ts
revoke(token: string): Promise<{ success: true }>
```

<TypeTable
type={{
token: { type: 'string', required: true, description: "폐기할 액세스 토큰입니다." },
}}
/>

```ts
await client.oauth.revoke(accessToken)
```

### oauth.getAppInfo(clientId, redirectUri?)
동의 화면에 쓸 앱의 공개 데이터를 반환합니다(`GET /v1/oauth/app-info`). 토큰이 필요하지 않습니다.

```ts
getAppInfo(clientId: string, redirectUri?: string): Promise<OAuthAppInfo>
```

<TypeTable
type={{
clientId: { type: 'string', required: true, description: "앱의 클라이언트 ID입니다." },
redirectUri: { type: 'string', description: "확인할 리디렉션 URI입니다. 응답이 정확히 이 URI가 등록되어 있는지 알려 줍니다." },
}}
/>

```ts
const info = await client.oauth.getAppInfo(clientId, "https://yourapp.com/oauth/callback")
if (!info.redirectUriRegistered) throw new Error("Unregistered redirect URI")
```

응답에는 `name`, `description`, `logoUrl`, `homepageUrl`, `scopesRequested`, `redirectUriRegistered`가 있습니다. `redirectUriRegistered`는 정확히 등록된 URI일 때만 `true`이고, `redirectUri`를 전달하지 않으면 `null`입니다. 이 값으로 동의 화면은 URI 목록을 드러내지 않고도 등록되지 않은 리디렉션을 거부할 수 있습니다.

## 자주 묻는 질문

### SDK로 OAuth 앱을 등록하려면 어떻게 하나요?

이름, 리디렉션 URI 1개 이상, 필요한 스코프를 넣어 client.developerApps.create를 호출하세요. 응답에 clientId와 clientSecret이 들어 있습니다. 시크릿은 한 번만 표시되므로 바로 저장하세요.

### 브라우저에서 OAuth 코드를 교환할 수 있나요?

아니요. client.oauth.exchangeCode에는 클라이언트 시크릿이 필요하며, 시크릿은 내 서버에만 있어야 합니다. 교환은 서버 코드에서 실행하고, 브라우저에는 필요한 것만 전달하세요.

### OAuth 앱은 몇 개까지 등록할 수 있나요?

직접 등록하는 앱은 5개까지입니다. 6번째 앱을 만들면 400 limit_reached로 실패합니다. MCP 클라이언트처럼 스스로 등록한 앱은 개수에 포함되지 않습니다.

### 클라이언트 시크릿을 교체하면 어떻게 되나요?

client.developerApps.rotateSecret은 새 시크릿을 만들고 이전 시크릿을 즉시 무효화합니다. 새 시크릿은 한 번만 반환됩니다.
