# OAuth apps

> Register an OAuth app, send users to the Nodaro consent screen, exchange the code for a 90-day access token, and call the API on each user's behalf.

Source: https://nodaro.ai/docs/developers/oauth

An **OAuth app** lets your product call the Nodaro API on behalf of other Nodaro users. Each user approves your app on a consent screen, your server exchanges the resulting code for an access token, and every call with that token acts as that user, limited to the scopes they granted. Nodaro implements the standard OAuth 2.0 authorization-code flow, with PKCE for clients that cannot keep a secret.

Developer apps are available on Nodaro Cloud and on Business edition installs.

## OAuth or a personal API token

| You are building | Use | Token format |
| --- | --- | --- |
| A script, cron job, CI job or backend that uses your own account | A personal API token | `ndr_` followed by 64 hex characters |
| A hosted product whose users have their own Nodaro accounts | An OAuth app | `ndr_app_` followed by 64 hex characters |

Use a **personal API token** when only your own account authenticates, and you need no consent screen and no revocation per user. See [Authentication](https://nodaro.ai/docs/developers/api/authentication).

Use **OAuth** in these cases:

- You build a hosted product, such as a web app, a SaaS or a marketplace, and your users have their own Nodaro accounts.
- Each user grants your app only part of what their account can do.
- Each user can cut off your app at any time without affecting other apps.

## How the flow works

1. The user clicks **Connect to Nodaro** on your site.
2. Your site sends the browser to Nodaro's `/oauth/authorize` page with your `client_id`, `redirect_uri`, `scope` and `state`.
3. Nodaro shows the consent screen, after a sign-in if needed. The screen shows your app's name, logo and requested scopes, and the account that will grant access. **Use a different account** signs the user out and returns to the same screen.
4. The user clicks **Allow**, and Nodaro creates a one-time authorization code.
5. The browser returns to your `redirect_uri` with `?code=...&state=...`.
6. Your server exchanges the code for an access token at `POST /v1/oauth/token`, with your `client_id` and `client_secret`.
7. Your server calls the Nodaro API with `Authorization: Bearer ndr_app_...` on the user's behalf.

## Register your app

### Open Developer Apps

On the Nodaro instance you target, open **Settings › Developer Apps** (`/settings/developer-apps`) and click **Create App**.

### Fill in the form

Enter the name, the redirect URIs and the scopes. The fields are described in the table below.

### Save the client secret

The **App Created** dialog shows the **Client ID**, which starts with `app_`, and the **Client Secret**, which starts with `sec_`. The secret is shown exactly once. Copy it to your secret manager before you close the dialog: Nodaro stores only a hash of it and cannot show it again.

| Field | Required | Rules |
| --- | --- | --- |
| **Name** | Yes | 1 to 100 characters. Shown on the consent screen. |
| **Description** | No | Up to 500 characters. Shown under the name on the consent screen. |
| **Redirect URIs** | Yes | 1 to 10, one per line. Each is an `https://` address, or an `http://localhost` address for development. Nodaro compares them byte for byte, and wildcards are not supported. |
| **Allowed origins** | No | Up to 5 bare origins, without a path, a query or a fragment. Needed only if your frontend calls Nodaro from a browser (CORS). |
| **Requested scopes** | Yes | At least one. This is the most your app may ever ask for: users can grant less, and your app can never request more. |
| **Homepage URL**, **Logo URL** | No | Set them on the app's page after you create it. Each is an `https://` or `http://localhost` address. A square logo looks best. |

### Manage the app

- **Rotate the secret** on the app's page when you lose it, or as routine security practice. The old secret stops working at once, so update the configuration of every running service right away.
- **Delete the app** to revoke every access token it was given. Users who authorized it must connect again.
- **Five apps per user.** MCP clients that registered themselves appear in the same list, but do not count toward the limit.
- **From code**, the SDK's `client.developerApps` creates, updates, deletes and rotates apps. See the [SDK](https://nodaro.ai/docs/developers/sdk).

## Scopes

A scope is one permission your app asks for. Request only the scopes you use: users see every requested scope on the consent screen, and a short list earns their trust.

| Scope | The consent screen says | What it allows |
| --- | --- | --- |
| `workflows:read` | Read your workflows | List, read and export workflows. |
| `workflows:write` | Create and modify workflows | Create, update, delete, import and move workflows, and create sub-workflows. |
| `workflows:execute` | Run workflows on your behalf | Run workflows and published apps, run single generation nodes through MCP, and use the prompt wizard. |
| `jobs:read` | Read job status and results | Read jobs, their status and their results. |
| `assets:read` | Read your uploaded assets | Read the gallery, uploads, favorites, app runs, characters, locations, objects and creatures. |
| `assets:write` | Upload assets to your account | Upload media, favorite assets, and create and update characters, locations and objects. |
| `credits:read` | See your credit balance | Read the credit balance and the credit transactions. |
| `apps:read` | Read published apps | List published apps and read their inputs. |
| `pipelines:read` | Read your pipelines | Read Story-to-Video pipelines, their status and their pending approvals. |
| `pipelines:execute` | Run pipelines on your behalf (this can spend your credits) | Start pipelines, run their stages and branch from a stage. |
| `pipelines:approve` | Approve pipeline stages on your behalf | Approve stage output, and use the stage chat and the scene helpers. |
| `presets:read` | Read your saved presets | Read the user's node presets and favorite presets. |
| `workspaces:read` | See the workspaces you belong to | List the user's workspaces. |
| `workspaces:write` | Choose which workspace it works in | Choose the workspace the app works in. |

- Some scopes gate REST routes, some gate [MCP tools](https://nodaro.ai/docs/mcp/tools), and some gate both. The MCP server hides every tool whose scope the token lacks.
- A token without the scope a route needs gets `403 insufficient_scope`, with the missing scope in `missingScope`. See [Errors](#errors).
- The workspace scopes are never added to a token issued before organizations existed. The user must authorize your app again to grant them.
- Running a published app needs `workflows:execute` to start the run and `jobs:read` to read its progress.

## Send the user to the consent screen

When the user clicks **Connect to Nodaro**, send the browser to this URL:

```text
https://nodaro.example.com/oauth/authorize?
client_id=app_...&
redirect_uri=https://yourapp.com/oauth/callback&
response_type=code&
scope=workflows:read+workflows:execute&
state=<random CSRF token>
```

| Parameter | Rule |
| --- | --- |
| `client_id` | Your app's client ID. |
| `redirect_uri` | Exactly one of your registered redirect URIs, byte for byte. A mismatch is refused with `400 invalid_redirect_uri`. |
| `response_type` | Always `code`. The consent screen refuses any other value. |
| `scope` | The scopes you request, separated by spaces or `+`. They must be a subset of the app's requested scopes. |
| `state` | A random token you create for each authorization and keep in the user's session. Nodaro returns it unchanged, and you must check it. |

Create `state` on your server:

```ts

// In your /connect handler:
const state = randomBytes(32).toString("hex")
req.session.oauthState = state

const url = new URL("https://nodaro.example.com/oauth/authorize")
url.searchParams.set("client_id", process.env.NODARO_CLIENT_ID!)
url.searchParams.set("redirect_uri", "https://yourapp.com/oauth/callback")
url.searchParams.set("response_type", "code")
url.searchParams.set("scope", "workflows:read workflows:execute")
url.searchParams.set("state", state)
res.redirect(url.toString())
```

- **If the user clicks Cancel,** Nodaro redirects to your `redirect_uri` with `error=access_denied`, an `error_description` and your `state`. Treat it as a normal outcome, not as a fault.
- **If the redirect URI is not registered,** the consent screen shows an error page and redirects nowhere, on Cancel as on Allow.

## Exchange the code for a token

After the user clicks **Allow**, Nodaro sends the browser to your callback:

```text
https://yourapp.com/oauth/callback?code=ndr_code_...&state=<your state>
```

1. **Check `state` first.** If it does not match the value in the user's session, stop: this is the protection against cross-site request forgery.
2. **Exchange the code on your server.** Never make this call from a browser, where anyone with developer tools could read your `client_secret`.

**TypeScript SDK**

```ts

// The token endpoint is public: your client ID and secret authenticate
// the request, so the client needs no token of its own.
const client = createClient({
baseUrl: "https://nodaro.example.com",
auth: new StaticTokenAuth(""),
})

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://yourapp.com/oauth/callback",
})
// tokens.access_token: "ndr_app_..."
// tokens.scope:        the scopes the user granted, separated by spaces
// tokens.expires_in:   7776000 (seconds, that is 90 days)
// tokens.token_type:   "Bearer"
```

**curl**

```bash
curl -X POST https://nodaro.example.com/v1/oauth/token \
  -H "Content-Type: application/json" \
  -d '{
"grant_type": "authorization_code",
"client_id": "app_...",
"client_secret": "sec_...",
"code": "ndr_code_...",
"redirect_uri": "https://yourapp.com/oauth/callback"
}'
```

The response uses the standard OAuth field names:

```json
{
"access_token": "ndr_app_...",
"token_type": "Bearer",
"scope": "workflows:read workflows:execute",
"expires_in": 7776000
}
```

- **A code works once.** A second exchange of the same code returns `400 invalid_grant`.
- **A code expires 10 minutes after it is issued.** Exchange it as soon as your callback receives it.
- **The token endpoint accepts JSON and form-encoded bodies.** Standard OAuth clients that post `application/x-www-form-urlencoded` work unchanged, with the `client_secret_post` method.

## Public clients: PKCE

Mobile apps, single-page apps and CLI tools cannot keep a `client_secret`. They use PKCE instead. Nodaro supports the `S256` method only; `plain` is refused with `400 invalid_request`.

### Create a verifier and a challenge

Before the redirect, create a random, high-entropy `code_verifier`. Derive `code_challenge` as the base64url encoding of the SHA-256 hash of the verifier.

### Send the challenge with the authorize request

Add `code_challenge` and `code_challenge_method=S256` to the authorize URL:

```text
https://nodaro.example.com/oauth/authorize?
client_id=app_...&
redirect_uri=https://yourapp.com/oauth/callback&
response_type=code&
scope=workflows:read+workflows:execute&
state=<random CSRF token>&
code_challenge=<base64url SHA-256 of the verifier>&
code_challenge_method=S256
```

### Send the verifier with the token exchange

Send `code_verifier` instead of `client_secret`:

```bash
curl -X POST https://nodaro.example.com/v1/oauth/token \
  -H "Content-Type: application/json" \
  -d '{
"grant_type": "authorization_code",
"client_id": "app_...",
"code": "ndr_code_...",
"redirect_uri": "https://yourapp.com/oauth/callback",
"code_verifier": "<the original verifier>"
}'
```

A confidential client may send both a secret and a PKCE verifier. Nodaro checks each one that is present.

## Call the API with the token

Create one client per user with that user's access token:

```ts

const userClient = createClient({
baseUrl: "https://nodaro.example.com",
auth: new StaticTokenAuth(tokens.access_token),
})

// Every call acts as the user who authorized your app, within the granted scopes.
const projects = await userClient.projects.list()
const workflows = await userClient.workflows.list(projects.data[0].id)
const run = await userClient.workflows.run(workflows.data[0].id)
```

Without the SDK, send the token in the `Authorization: Bearer` header of each REST call. The endpoints are in the [REST API](https://nodaro.ai/docs/developers/api) reference.

## When a token expires

An access token lasts **90 days**. Nodaro issues **no refresh tokens**: there is one token type, one place to store it and one expiry rule, at the cost of a consent screen every 90 days.

- After the token expires or is revoked, API calls return `401`. Send the user to `/oauth/authorize` again.
- When the user authorizes your app again, Nodaro updates their authorization and issues a new token. Earlier tokens stay valid until they expire or are revoked, so keep track of which tokens are in use.
- To add a scope, send the user through the authorize URL with the broader `scope`. The existing authorization is widened.

## Store tokens safely

- **Keep tokens on your server.** Never put an access token in `localStorage`, `sessionStorage` or a cookie that JavaScript can read.
- **Encrypt tokens at rest** if your platform supports it. Nodaro keeps only a SHA-256 hash of each token, so a leak of your own database is the only way a token can escape.
- **Never share a token between users.** Each token belongs to one Nodaro user; using it in another user's context is an authorization bug.

## Revoke a token

Revoke a token when the user signs out of your app, deletes their account on your platform, or clicks **Disconnect Nodaro** in your app's settings.

**TypeScript SDK**

```ts
await client.oauth.revoke(tokens.access_token)
// { success: true }
```

**curl**

```bash
curl -X POST https://nodaro.example.com/v1/oauth/revoke \
  -H "Content-Type: application/json" \
  -d '{ "token": "ndr_app_..." }'
```

The revoke endpoint always answers `200`, even for a token that does not exist, so nobody can use it to test whether a guessed token is valid. After a revocation, calls with the token return `401`.

## Errors

The token endpoint answers with standard OAuth errors:

| Status | Error | When | What to do |
| --- | --- | --- | --- |
| `400` | `invalid_request` | The body is malformed or a field is missing. | Fix the request. |
| `401` | `invalid_client` (Unknown client) | The `client_id` matches no registered app. | Check the client ID in your configuration. |
| `401` | `invalid_client` (Bad client_secret) | The secret is wrong, often an old one after a rotation. | Load the current secret and restart your service. |
| `400` | `invalid_grant` | The code is older than 10 minutes, was already used, or the `redirect_uri` differs from the one on the authorize request. | Send the user through consent again, with identical URIs. |

The authorize step answers the consent screen, and never redirects with these errors:

| Status | Code | When | What to do |
| --- | --- | --- | --- |
| `400` | `invalid_redirect_uri` | The `redirect_uri` is not registered for the app. | Add the URI on the app's page. |
| `400` | `invalid_scope` | A requested scope is not in the app's requested scopes. | Add the scope to the app first. |
| `404` | `invalid_client` | The `client_id` is unknown, or the app is suspended. | Check the client ID and the app's status. |

API calls with the access token can fail with:

| Status | Code | When | What to do |
| --- | --- | --- | --- |
| `401` | `unauthorized` | The token expired, was revoked or is malformed. | Send the user through consent again. |
| `403` | `insufficient_scope` | The token lacks the scope the route needs. The body names it in `missingScope`. | Send the user through consent with the broader scope. |

The SDK raises the `403` case as a `ForbiddenError` with a typed `missingScope`, so you can offer a one-click re-consent:

```ts

try {
await userClient.workflows.run(workflowId)
} catch (err) {
if (err instanceof ForbiddenError && err.missingScope) {
// Send the user back to /oauth/authorize with the broader scope list.
redirectToConsent({ scopes: [...currentScopes, err.missingScope] })
return
}
throw err
}
```

## Security checklist

- **HTTPS everywhere.** The Nodaro instance, your app and every redirect URI use `https://`. `http://localhost` is for local development only.
- **Check `state` on every callback.** Create it per authorization and keep it in the user's session.
- **Keep `client_secret` on your server.** Never bundle it into a browser app, log it or echo it in an error message.
- **Rotate the secret** at least once a year, and at once if you suspect a leak.
- **Register only your own redirect URIs.** Register only the addresses you actually use.
- **Request the smallest set of scopes.** Add a scope when you build the feature that needs it.
- **Revoke on sign-out.** Call the revoke endpoint when a user signs out of your app, so their token cannot be replayed.
- **Handle `missingScope`.** Offer the user a re-consent, not a generic "permission denied" page.

## Test the flow locally

The quickest test loop needs a Nodaro instance with **Developer Apps**, such as Nodaro Cloud or a Business edition install, and a small callback server on your machine.

### Register a test app

Create an app with the redirect URI `http://localhost:8080/cb` and the scopes `workflows:read`, `workflows:execute` and `jobs:read`.

### Run a callback server on port 8080

```ts

const NODARO_URL = "https://app.nodaro.ai" // or your Business edition install

const app = express()

app.get("/cb", async (req, res) => {
const client = createClient({ baseUrl: NODARO_URL, auth: new StaticTokenAuth("") })
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: "http://localhost:8080/cb",
})
res.json(tokens)
})

app.listen(8080)
```

### Open the authorize URL and click Allow

Open `/oauth/authorize?client_id=app_...&redirect_uri=http://localhost:8080/cb&response_type=code&scope=workflows:read+workflows:execute&state=test123` on the instance. After you click **Allow**, the callback server shows the token JSON.

### Call a real route

Use the token on a route such as `GET /v1/projects/<id>/workflows`, and check that it answers `200` with data.

## Discovery and dynamic registration for MCP clients

MCP clients, such as Claude, ChatGPT and Cursor, find the OAuth endpoints on their own and can register themselves at runtime, without a person visiting Developer Apps. See [Connect a client](https://nodaro.ai/docs/mcp/connect) for the user side.

### Discovery documents

| Endpoint | Standard | Purpose |
| --- | --- | --- |
| `GET /.well-known/oauth-authorization-server` | RFC 8414 | Where to authorize, get tokens, register and revoke. |
| `GET /.well-known/oauth-protected-resource` | RFC 9728 | Binds the MCP resource, `https://mcp.nodaro.ai/mcp`, to its authorization server. |

Each document is also served with a `/mcp` suffix, because some strict clients try that form first. All four resolve on both the Nodaro host and the MCP host. The issuer is the instance's `PUBLIC_URL`, `https://app.nodaro.ai` on Nodaro Cloud.

The authorization server metadata advertises:

- the authorization, token, registration and revocation endpoints;
- the `code` response type and the `authorization_code` grant type;
- PKCE with the `S256` method only;
- the `client_secret_post` authentication method at the token endpoint;
- every scope in `scopes_supported`.

### Dynamic client registration

A client registers itself with `POST /v1/oauth/register` (RFC 7591) and receives a `client_id` that starts with `ndr_dcr_`, plus a `client_secret`. The endpoint accepts 10 requests per minute per IP address. The operator of the instance sets `MCP_DYNAMIC_REGISTRATION`:

| Mode | Behavior |
| --- | --- |
| `allowlist` (default) | Only client names on `MCP_DCR_ALLOWLIST` may register. Others get `403 client_not_allowed`. |
| `open` | Any client may register, up to 5 unused registrations per client name and redirect URIs in 24 hours (`429 too_many_open_registrations`). |
| `off` | Registration is disabled (`403 dcr_disabled`). The operator hands out a fixed client ID and secret instead. |

A client that registered itself chose its own name, so the consent screen warns the user that Nodaro did not verify it. The scopes it declares are informational: the user's consent is the real gate, and it may request any valid scope. See [MCP on a self-hosted install](https://nodaro.ai/docs/self-hosting/mcp) for the operator settings.

## The plugin for Figma on a self-hosted install

The Nodaro plugin for Figma runs inside Figma and cannot receive a redirect. It connects with a device-style handshake instead: the plugin shows the user a short code, the user approves the plugin on the ordinary consent screen and types that code, and the plugin then receives its token. The token is an ordinary developer-app token with the scopes `jobs:read`, `assets:read`, `assets:write` and `credits:read`, revocable like any other.

To let the plugin connect to your own install:

1. Register a developer app, in **Developer Apps** or with `POST /v1/developer-apps`. Add `<PUBLIC_URL>/v1/oauth/plugin/callback` to its redirect URIs, and request the four scopes above.
2. Put the app's client ID in `FIGMA_PLUGIN_OAUTH_CLIENT_ID` on the server.

Without that setting, every plugin connect route answers `503 plugin_connect_not_configured`. If the app lacks the callback URI or a scope, the routes answer `503 plugin_connect_misconfigured`, and the server log names what is missing.

## Frequently asked questions

### When do I need OAuth instead of a personal API token?

Use OAuth when your product acts on other people's Nodaro accounts and each user must approve your app and be able to cut it off. For scripts and servers that only use your own account, a personal API token is simpler.

### How long does a Nodaro OAuth access token last?

90 days. Nodaro issues no refresh tokens, so when a token expires or is revoked, API calls return 401 and you send the user through the consent screen again.

### Does Nodaro support PKCE?

Yes, with the S256 method only. Mobile apps, single-page apps and CLI tools send a code_challenge on the authorize request and the matching code_verifier on the token exchange, instead of a client secret.

### What happens if I lose my client secret?

Nodaro stores only a hash of the secret and cannot show it again. Rotate the secret on the app's page to get a new one. The old secret stops working at once.

### How many developer apps can I register?

Five per user. MCP clients that register themselves appear in the same list but do not count toward the limit.
