# OAuth and developer apps

> Register and manage your Nodaro OAuth apps with client.developerApps, and exchange codes, revoke tokens and read consent-screen data with client.oauth.

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

**`client.developerApps`** manages the OAuth apps you own: apps that let other Nodaro users grant your software access to their accounts. **`client.oauth`** covers the server side of the OAuth 2.0 flow: it exchanges an authorization code for an access token, revokes tokens, and returns the public data a consent screen shows. The full consent flow is described in [OAuth](https://nodaro.ai/docs/developers/oauth).

## Methods

| Method | What it does |
| --- | --- |
| [`developerApps.list()`](#developerappslist) | List your apps |
| [`developerApps.get(id)`](#developerappsgetid) | Read one app |
| [`developerApps.create(input)`](#developerappscreateinput) | Register an app and get its secret |
| [`developerApps.update(id, input)`](#developerappsupdateid-input) | Change an app |
| [`developerApps.delete(id)`](#developerappsdeleteid) | Delete an app |
| [`developerApps.rotateSecret(id)`](#developerappsrotatesecretid) | Replace an app's secret |
| [`oauth.exchangeCode(input)`](#oauthexchangecodeinput) | Exchange an authorization code for an access token |
| [`oauth.revoke(token)`](#oauthrevoketoken) | Revoke an access token |
| [`oauth.getAppInfo(clientId, redirectUri?)`](#oauthgetappinfoclientid-redirecturi) | Read an app's public data for a consent screen |

## Scopes

An app asks for scopes, and a user approves them. The SDK types these scopes as `DeveloperAppScope`:

| Scope | Allows |
| --- | --- |
| `workflows:read` | Reading workflows |
| `workflows:write` | Creating and changing workflows |
| `workflows:execute` | Running workflows |
| `jobs:read` | Reading jobs |
| `assets:read` | Reading characters, locations, objects and other assets |
| `assets:write` | Creating and changing assets |
| `credits:read` | Reading the credit balance |
| `apps:read` | Reading published apps |
| `pipelines:read` | Reading pipelines |
| `pipelines:execute` | Starting and cancelling pipelines |
| `pipelines:approve` | Approving and rejecting pipeline stages |

A call without the scope it needs throws a `ForbiddenError` whose `missingScope` names it. See [Scopes and missing permissions](https://nodaro.ai/docs/developers/sdk/auth#scopes-and-missing-permissions).

## client.developerApps

Only the owner can read or change an app. Secrets are returned exactly once.

### developerApps.list()

Lists your apps.

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

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

A `DeveloperApp` has `id`, `name`, `description`, `logoUrl`, `homepageUrl`, `redirectUris`, `allowedOrigins`, `scopesRequested`, `clientId`, `status` (`active`, `suspended` or `pending_review`), `kind`, `createdAt` and `updatedAt`. `kind` is `"user"` for an app you registered. The other values, `dynamic_mcp`, `first_party_mcp` and `community_instance`, are clients that registered themselves. Only `"user"` apps count toward the limit of five.

### developerApps.get(id)

Reads one app. The secret is never included.

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

<TypeTable
type={{
id: { type: 'string', required: true, description: "The app id." },
}}
/>

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

### developerApps.create(input)

Registers an app. The answer includes `clientSecret`. Store it now: the server keeps only a hash of it.

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

<TypeTable
type={{
name: { type: 'string', required: true, description: "The app name users see on the consent screen." },
redirectUris: { type: 'string[]', required: true, description: "1 to 10 URIs, each https:// or http://localhost." },
scopesRequested: { type: 'DeveloperAppScope[]', required: true, description: "At least one scope." },
allowedOrigins: { type: 'string[]', description: "Up to 5 bare origins, without a path, query or hash, that may call the API from a browser with this app's tokens." },
description: { type: 'string', description: "A description for the consent screen." },
homepageUrl: { type: 'string', description: "Your app's home page." },
logoUrl: { type: 'string', description: "Your app's logo." },
}}
/>

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

You can register five apps. A sixth fails with `400 limit_reached`.

### developerApps.update(id, input)

Changes an app. Pass only the fields to change; the rules of `create()` apply to each.

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

<TypeTable
type={{
id: { type: 'string', required: true, description: "The app id." },
name: { type: 'string', description: "The app name." },
description: { type: 'string', description: "The description." },
homepageUrl: { type: 'string', description: "The home page." },
logoUrl: { type: 'string', description: "The logo." },
redirectUris: { type: 'string[]', description: "The complete new list of redirect URIs." },
allowedOrigins: { type: 'string[]', description: "The complete new list of browser origins." },
scopesRequested: { type: 'DeveloperAppScope[]', description: "The complete new list of scopes." },
}}
/>

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

### developerApps.delete(id)

Deletes an app.

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

<TypeTable
type={{
id: { type: 'string', required: true, description: "The app id." },
}}
/>

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

Throws `NotFoundError` when the id does not exist or the app is not yours.

### developerApps.rotateSecret(id)

Creates a new client secret and invalidates the old one at once. The new secret is returned only once.

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

<TypeTable
type={{
id: { type: 'string', required: true, description: "The app id." },
}}
/>

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

Update your server's secret right away, because code exchanges with the old secret stop working.

## client.oauth

The OAuth 2.0 endpoints your app's server calls. Field names use snake_case, as the OAuth standard does.

### oauth.exchangeCode(input)

Exchanges the authorization code from the consent redirect for an access token (`POST /v1/oauth/token`). The SDK adds `grant_type: "authorization_code"` for you.

**Never call it from a browser.** The request contains your client secret, which must stay on your server.

```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: "Your app's client id." },
client_secret: { type: 'string', required: true, description: "Your app's client secret." },
code: { type: 'string', required: true, description: "The code from the consent redirect." },
redirect_uri: { type: 'string', required: true, description: "The same redirect URI that was used to get the code." },
}}
/>

```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` lists the granted scopes, separated by spaces, and `expires_in` is the token's lifetime in seconds.

### oauth.revoke(token)

Revokes an access token (`POST /v1/oauth/revoke`, RFC 7009). It always answers `{ success: true }`, even for an unknown token, because the standard forbids revealing whether a token was valid.

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

<TypeTable
type={{
token: { type: 'string', required: true, description: "The access token to revoke." },
}}
/>

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

### oauth.getAppInfo(clientId, redirectUri?)

Returns an app's public data for a consent screen (`GET /v1/oauth/app-info`). It needs no token.

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

<TypeTable
type={{
clientId: { type: 'string', required: true, description: "The app's client id." },
redirectUri: { type: 'string', description: "A redirect URI to check. The answer then says whether exactly this URI is registered." },
}}
/>

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

The answer has `name`, `description`, `logoUrl`, `homepageUrl`, `scopesRequested` and `redirectUriRegistered`. `redirectUriRegistered` is `true` only for an exactly registered URI, and `null` when you pass no `redirectUri`. It lets a consent screen refuse an unregistered redirect without revealing the list of URIs.

## Frequently asked questions

### How do I register an OAuth app with the SDK?

Call client.developerApps.create with a name, at least one redirect URI and the scopes you need. The answer includes the clientId and the clientSecret. The secret is shown only once, so store it right away.

### Can I exchange an OAuth code in the browser?

No. client.oauth.exchangeCode needs your client secret, which must stay on your server. Run the exchange in server code and give the browser only what it needs.

### How many OAuth apps can I register?

Five apps that you register yourself. A sixth create fails with 400 limit_reached. Apps that registered themselves, such as MCP clients, do not count.

### What happens when I rotate a client secret?

client.developerApps.rotateSecret creates a new secret and invalidates the old one at once. The new secret is returned only once.
