OAuth 2.0 Flow
This guide walks through the full Nodaro OAuth 2.0 authorization-code flow: when to use it, how to register an app, the seven steps from “Connect to Nodaro” button to authenticated API call, scope semantics, common errors, and where Nodaro deviates from RFC 6749.
If you only need to call the API as yourself (one Nodaro account, one backend), OAuth is overkill — use a personal API token instead. See When you need OAuth.
1. When you need OAuth
There are two ways to authenticate against the Nodaro API:
| Use case | Auth method | Token format |
|---|---|---|
| Server-to-server for your own account | Personal API token | ndr_<...> |
| Third-party app acting on other users’ accounts | OAuth 2.0 access token | ndr_app_<...> |
Use a personal API token when:
- You’re scripting your own Nodaro account from a server cron, CI job, or internal tool.
- Only your own Nodaro user ever needs to authenticate.
- You don’t need a consent screen or per-user revocation.
Personal API tokens are minted from /settings/api-tokens and live
indefinitely until revoked. See API Integration
for details.
Use OAuth when:
- You’re building a hosted product (web app, SaaS, marketplace) and your end users have their own Nodaro accounts.
- You need users to grant your app a scoped subset of their account’s capabilities.
- You want users to be able to revoke your app at any time without affecting other apps.
The rest of this doc covers OAuth.
2. Big picture: the 7-step flow
Nodaro implements the standard OAuth 2.0 authorization-code grant (RFC 6749 §4.1) with a few modern simplifications (see §13).
sequenceDiagram
participant Browser
participant ThirdPartyApp as Third-party site
participant Nodaro as Nodaro frontend
participant Backend as Nodaro backend
Browser->>ThirdPartyApp: User clicks "Connect to Nodaro"
ThirdPartyApp-->>Browser: Redirect to /oauth/authorize?...
Browser->>Nodaro: GET /oauth/authorize?client_id=&...
Note over Nodaro: Login if needed, render consent UI
Browser->>Backend: POST /v1/oauth/authorize (user JWT)
Backend-->>Browser: { code, redirectUri, state }
Browser->>ThirdPartyApp: Redirect to redirectUri?code=&state=
ThirdPartyApp->>Backend: POST /v1/oauth/token (client_id + client_secret)
Backend-->>ThirdPartyApp: { access_token, scope, expires_in }
ThirdPartyApp->>Backend: API call with Authorization: Bearer ndr_app_...
Backend-->>ThirdPartyApp: 200 { data: ... }
The seven steps in plain English:
- User clicks “Connect to Nodaro” on the third-party site.
- Third-party site redirects the browser to Nodaro’s
/oauth/authorizepage withclient_id,redirect_uri,scope, andstatein the query string. - Nodaro renders the consent screen (after a login if needed), showing the app’s name, logo, and the requested scopes. It also names the signed-in account the token will belong to — the browser’s current session — and offers Use a different account, which signs out and returns to the same consent URL so the grant can be made from another account.
- User clicks Allow. The frontend POSTs to
/v1/oauth/authorize(authenticated as the user) and receives a one-shot authorizationcode. - Browser is redirected back to the third-party site’s
redirect_uriwith?code=...&state=.... - Third-party server exchanges the code for an access token at
/v1/oauth/token, authenticating withclient_id+client_secret. - Third-party server uses the token to call the Nodaro REST API on
the user’s behalf, sending
Authorization: Bearer ndr_app_<...>.
3. Step 1: Register your app
Open /settings/developer-apps on the Nodaro instance you’re targeting
(e.g. https://nodaro.example.com/settings/developer-apps) and click
New developer app.
Fill in the form:
| Field | Required | Constraints |
|---|---|---|
| Name | yes | 1-100 chars. Shown to users on the consent screen. |
| Description | no | ≤500 chars. Shown under the name on the consent screen. |
| Homepage URL | no | https://... only (http://localhost allowed for dev). |
| Logo URL | no | Same scheme rules as homepage. Square images render best. |
| Redirect URIs | yes | 1-10 entries. Exact-match URIs your server will receive the code at. Wildcards are NOT supported. |
| Allowed origins | no | 0-5 bare origins (no path, no query, no hash) for CORS. Required only if your frontend ever calls Nodaro from a browser. |
| Scopes requested | yes | At least 1 scope. Superset of what your app might ever request — users can grant a subset, but you cannot exceed this list at authorize-time. |
On save, a modal shows your clientId (format app_<32hex>) and
clientSecret (format sec_<64hex>).
clientSecretis shown EXACTLY ONCE. Copy it into your secret manager before dismissing the modal — Nodaro stores only a bcrypt hash and cannot recover the plaintext.
If you lose the secret (or want to rotate it as a security hygiene
measure), use the Rotate secret button on the app’s detail page —
this calls POST /v1/developer-apps/:id/rotate-secret and returns a
new secret. The old secret is invalidated immediately, so any
running services need their config updated before existing tokens
expire (or refresh, if you mint new ones).
You can register up to 5 apps per user. Only apps you registered by
hand count (kind: "user" in the list response); MCP clients that
registered themselves (see the next section) appear in the same list but
do not use up the cap. Admin accounts are not capped.
Discovery & Dynamic Client Registration (MCP clients)
Manual app registration (above) is the path for traditional web/SaaS products. MCP clients (Claude.ai, Cursor, etc.) instead expect to discover the OAuth endpoints automatically and, optionally, register themselves at runtime — no human visiting a dashboard. Both are supported.
Discovery endpoints
Two metadata documents are served at well-known URLs:
| Endpoint | RFC | Purpose |
|---|---|---|
GET /.well-known/oauth-authorization-server |
RFC 8414 | Authorization-server metadata (where to authorize, get tokens, register, revoke). |
GET /.well-known/oauth-protected-resource |
RFC 9728 | Binds the MCP resource (https://mcp.nodaro.ai/mcp) to its authoritative auth server. |
Each is also served at a /mcp-suffixed variant
(/.well-known/oauth-authorization-server/mcp and
/.well-known/oauth-protected-resource/mcp) because some strict clients
(notably Cursor) probe the resource-path form first and treat a 404
there as a hard auth failure.
All four documents resolve on both hosts — the auth server
(app.nodaro.ai) and the MCP resource host (mcp.nodaro.ai). The
authoritative authorization-server metadata lives on the auth server, but
the MCP host mirrors it because some clients (legacy MCP discovery) probe
the resource host for it and treat a 404 as a failed handshake.
The issuer is PUBLIC_URL (default https://app.nodaro.ai). The
authorization-server document advertises:
authorization_endpoint,token_endpoint,registration_endpoint,revocation_endpointresponse_types_supported: ["code"],grant_types_supported: ["authorization_code"]code_challenge_methods_supported: ["S256"](PKCE — S256 only)token_endpoint_auth_methods_supported: ["client_secret_post"]- all 11 entries in
scopes_supported
Dynamic Client Registration (DCR)
POST /v1/oauth/register
Per RFC 7591, a client can
register itself and receive a client_id (format ndr_dcr_<32hex>) plus
a client_secret. The endpoint is rate-limited to 10 requests/minute
per IP.
DCR is governed by the MCP_DYNAMIC_REGISTRATION operator setting:
| Mode | Behaviour |
|---|---|
allowlist (default) |
Only client_names on MCP_DCR_ALLOWLIST may register; others get 403 client_not_allowed. |
open |
Any client may register, but there’s a cap of 5 unconsumed registrations per (client_name + redirect_uris) within a 24h window (429 too_many_open_registrations once exceeded). |
off |
DCR is disabled entirely (403 dcr_disabled); operators hand out a static client_id/client_secret instead. |
A dynamically-registered MCP client carries kind=dynamic_mcp. (Self-hosted
community installs also register at this endpoint — with
kind=community_instance, governed by the separate connect flow in the
community cloud-connect guide, not by the modes
above.) Because a dynamic_mcp client self-claimed its name, the consent
screen renders an extra warning so the user knows the app identity wasn’t
vetted by an operator. Its declared
scopes are informational — the user-controlled consent screen is the
actual gate, so any valid Nodaro scope may be requested at authorize-time.
4. Scope vocabulary
There are 11 scopes total. The canonical list lives in
backend/src/lib/scopes.ts.
| Scope | What it grants | Routes gated today |
|---|---|---|
workflows:read |
Read the user’s workflows | GET /v1/projects/:projectId/workflows, GET /v1/workflows, GET /v1/workflows/:id, GET /v1/workflows/:id/export |
workflows:write |
Create and modify workflows | POST /v1/projects/:projectId/workflows, POST /v1/workflows, PATCH /v1/workflows/:id, DELETE /v1/workflows/:id, POST /v1/workflows/import, POST /v1/workflows/:parentId/sub-workflows |
workflows:execute |
Run workflows on the user’s behalf | POST /v1/workflows/:id/run; the prompt-wizard MCP tools |
jobs:read |
Read job status and results | GET /v1/jobs/status, GET /v1/jobs/:id, GET /v1/jobs/:id/status, GET /v1/jobs, POST /v1/jobs/batch-status |
assets:read |
Read the user’s uploaded assets | (reserved) |
assets:write |
Upload assets to the user’s account | (reserved) |
credits:read |
See the user’s credit balance | (reserved) |
apps:read |
Read published apps | (reserved) |
pipelines:read |
Read the user’s Story-to-Video pipelines | GET /v1/pipelines/* |
pipelines:execute |
Run / branch pipeline stages | POST /v1/pipelines/:id/branch and run routes |
pipelines:approve |
Approve pipeline stage output | pipeline approval routes; the scene-helper routes (POST /v1/pipelines/:id/entities/:sceneId/helpers/*) |
Honest disclosure: most scopes gate real routes today —
workflows:read,workflows:write,workflows:execute,jobs:read, and the threepipelines:*scopes. Only four are reserved names that future routes will gate:assets:read,assets:write,credits:read, andapps:read. Request scopes only if you actually intend to use them — minimal scope sets earn user trust.
The exact scope description shown to the user on the consent screen is
defined in frontend/src/app/oauth/authorize/page.tsx.
5. Step 2: Build the redirect URL
When the user clicks “Connect to Nodaro”, redirect their browser to:
https://nodaro.example.com/oauth/authorize?
client_id=app_<32hex>&
redirect_uri=https://yourapp.com/oauth/callback&
response_type=code&
scope=workflows:read+workflows:execute&
state=<random_csrf_token>
Required query parameters:
client_id— your app’s clientId from the registration modal.redirect_uri— must EXACTLY match one of your registered redirect URIs (byte-for-byte). The backend rejects mismatches with HTTP 400invalid_redirect_uri.response_type=code— onlycodeis supported. Other values cause the frontend to refuse to render the consent screen.scope— space-separated (or+-separated; both are accepted) list of scope strings. Must be a subset of the app’sscopes_requested.state— opaque CSRF token, generated per-authorization on your server and stored in a session cookie. The backend echoesstateunchanged on the redirect back. Always verify it matches before exchanging the code.
Generating state server-side:
import { randomBytes } from "node:crypto"
// 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 cancels (clicks “Cancel” on the consent screen), Nodaro
redirects to redirect_uri?error=access_denied&error_description=User+cancelled&state=<your_state>
— treat this as a non-error UX outcome, not a server fault.
The consent screen checks redirect_uri against the registered list before
it renders (GET /v1/oauth/app-info?client_id=&redirect_uri=). An
unregistered URI gets an error page and no redirect at all, on Cancel as
much as on Allow (RFC 6749 §3.1.2.4).
6. Step 3: Exchange code for token
After the user clicks Allow, Nodaro redirects the browser to:
https://yourapp.com/oauth/callback?code=ndr_code_<48hex>&state=<your_state>
The code format is ndr_code_ followed by 48 hex chars.
Step 1 in your callback handler: verify state. If it doesn’t
match the value you stored when generating the redirect URL, abort the
flow — this is the OAuth defense against CSRF.
Step 2: exchange the code on your server. Never put this call in
the browser — client_secret would leak to anyone with devtools.
Using the SDK
import { createClient, StaticTokenAuth } from "@nodaro/sdk"
// The token endpoint is public (auth is via client_id + client_secret in
// the body), so we pass an empty token here.
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_<64hex>"
// tokens.scope — space-separated granted scopes (subset of requested)
// tokens.expires_in — 7776000 (seconds = 90 days)
// tokens.token_type — "Bearer"
Using curl
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"
}'
Response (snake_case per RFC 6749):
{
"access_token": "ndr_app_<64hex>",
"token_type": "Bearer",
"scope": "workflows:read workflows:execute",
"expires_in": 7776000
}
Codes are one-shot. RFC 6749 mandates a code can be redeemed at most once. A second attempt returns HTTP 400
invalid_grant. Codes also expire 10 minutes after issue — design your callback handler to redeem immediately, not lazily.
Public clients (PKCE)
Mobile apps, SPAs, and CLI tools cannot keep a client_secret. Use PKCE
(RFC 7636) instead. S256 is
the only supported method — plain is rejected with HTTP 400
invalid_request.
- Before the redirect, generate a high-entropy
code_verifierand derivecode_challenge = base64url(SHA256(code_verifier)). -
On the authorize request (§5), add
code_challengeandcode_challenge_method=S256to the query string:https://nodaro.example.com/oauth/authorize? client_id=app_<32hex>& redirect_uri=https://yourapp.com/oauth/callback& response_type=code& scope=workflows:read+workflows:execute& state=<random_csrf_token>& code_challenge=<base64url_sha256>& code_challenge_method=S256 -
On the token exchange (this section), send
code_verifierinstead ofclient_secret: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": "<original_verifier>" }'
client_secret is optional for the PKCE path. Confidential clients may
send both — the server verifies whichever is present (and both, if both
are supplied).
Plugin connect handshake (Figma)
A plugin that runs inside another app — the Nodaro plugin for Figma — cannot
take the redirect above: its UI is an iframe with no address of its own, so
?code= has nowhere to land. Nodaro offers a second shape for exactly that
case, the one a TV uses to sign into a streaming service. The plugin opens a
short-lived session, sends the user to the ordinary consent screen, and polls
the session until the user has allowed access. Nothing is pasted, and no token
ever travels through a URL.
POST /v1/oauth/plugin/session { "client": "figma" }
-> { sessionId, pollKey, userCode, authorizeUrl, expiresIn }
GET /v1/oauth/plugin/callback?code=…&state=… (the consent screen's redirect)
-> a page asking the user to type the plugin's code
POST /v1/oauth/plugin/confirm state=…&code=… (that page's form)
-> "Connected", or the form again, or "cancelled" after five wrong codes
GET /v1/oauth/plugin/session/:id header: X-Plugin-Poll-Key: <pollKey>
-> { status: "pending" } | { status: "denied" }
-> { status: "granted", token, tokenType, scope, expiresIn } (once)
- The plugin calls
POST /v1/oauth/plugin/session. It gets three things:sessionId, which doubles as the OAuthstateand is treated as public;pollKey, which stays in the plugin and is what authorises a read; anduserCode, an eight-character code the plugin shows the user. - The plugin opens
authorizeUrlin the user’s browser — the normal/oauth/authorizeconsent screen, already carrying the plugin’sclient_id,redirect_uri,scopeand the session asstate. - The user allows or declines. The consent screen redirects the browser to
/v1/oauth/plugin/callback. On a decline the session is settled and the page says so. On an allow, the code is redeemed and the consent held — only for a live session, only if the code was issued to the plugin’s own app, only with exactly the plugin’s scopes, and never with a PKCE challenge this flow did not set — and the page asks for the code the plugin is showing. - The user types the code (
POST /v1/oauth/plugin/confirm). This is what ties the person who pressed Allow to the plugin that asked: a connect link forwarded to someone else dead-ends, because that someone never sees the sender’s code. Five wrong codes settle the session as denied. - The plugin polls with the poll key.
pendinguntil the code is confirmed;grantedexactly once, carrying anndr_app_…access token minted at that read — the session is claimed atomically and deleted in the same call, so Redis never holds a bearer token and two overlapping polls cannot both be minted one. A wrong poll key answers404, the same as an unknown session. Sessions live ten minutes.
The token is an ordinary developer-app token (§7), granted the scopes
jobs:read, assets:read, assets:write and credits:read, and revocable
like any other (§9).
Operator setup. The plugin connects through a developer app the operator
registers once — in the dashboard or with POST /v1/developer-apps — whose
redirect URIs include this deployment’s <PUBLIC_URL>/v1/oauth/plugin/callback
and whose requested scopes include the four above. Its client_id goes into
FIGMA_PLUGIN_OAUTH_CLIENT_ID (see deployment). Unset,
every plugin connect route answers 503 plugin_connect_not_configured; a
registration that is missing the callback or a scope answers
503 plugin_connect_misconfigured and names what is missing in the server log.
7. Step 4: Use the token
Configure a per-user client with the access token:
import { createClient, StaticTokenAuth } from "@nodaro/sdk"
const userClient = createClient({
baseUrl: "https://nodaro.example.com",
auth: new StaticTokenAuth(tokens.access_token),
})
// Now every call acts as the authorizing user, scoped to what they granted:
const projects = await userClient.projects.list()
const workflows = await userClient.workflows.list(projects.data[0].id)
const job = await userClient.workflows.run(workflows.data[0].id)
Tokens live 90 days (expires_in: 7776000 seconds). After expiry,
calls return HTTP 401 — at which point you re-prompt the user with a
fresh /oauth/authorize redirect.
There are no refresh tokens in the MVP. This is a deliberate tradeoff (see §13).
8. Storing tokens
A few rules:
- Server-side only. Never put
access_tokenin browser localStorage, sessionStorage, or in a cookie that JavaScript can read (HttpOnlyis fine if you’re using the cookie purely for your own app’s session, not for forwarding to Nodaro). - Encrypt at rest if your platform supports it. Nodaro stores a SHA-256 hash of the token, not the plaintext, so a database leak on your side is the only way the bearer token can escape.
- One token per (user, app, latest consent). If a user re-consents
for the same app, the authorization row is upserted (per-user-per-app
uniqueness on
developer_app_authorizations) and a new token is minted. Old tokens remain valid until they expire or are revoked, so track which tokens are in active use to avoid leaks. - Don’t share tokens between users. Each token authenticates a specific Nodaro user; using one user’s token in another user’s context is an authorization bug.
9. Revocation
Per RFC 7009, the revoke endpoint always returns HTTP 200 — even for unknown tokens. This prevents an attacker from probing token validity.
await client.oauth.revoke(tokens.access_token)
// → { success: true }
Or via curl:
curl -X POST https://nodaro.example.com/v1/oauth/revoke \
-H "Content-Type: application/json" \
-d '{ "token": "ndr_app_..." }'
Server-side, this sets revoked_at on the token row. Subsequent API
calls with the revoked token return HTTP 401.
Users can also revoke from the Nodaro UI at
/settings/developer-apps (the “Authorized apps” section lists every
app they’ve granted access to and offers a per-app revoke button).
Treat 401 errors as “the user revoked” → re-prompt for consent.
When to call revoke() from your code:
- User logs out of your app (revoke their Nodaro token along with their session).
- User deletes their account on your platform.
- User hits “Disconnect Nodaro” in your app’s settings.
10. Common errors
OAuth-specific errors returned by the token endpoint:
| HTTP status | Body | When | What to do |
|---|---|---|---|
| 400 | { "error": "invalid_request", "error_description": "..." } |
Malformed body, missing field | Fix request shape |
| 401 | { "error": "invalid_client", "error_description": "Unknown client" } |
client_id doesn’t match any registered app |
Check client_id env var |
| 401 | { "error": "invalid_client", "error_description": "Bad client_secret" } |
client_secret wrong (often: rotated and old one cached) |
Pull the latest secret; restart your service |
| 400 | { "error": "invalid_grant", "error_description": "Code expired, used, or redirect_uri mismatch" } |
Code >10 min old, redeemed twice, or redirect_uri doesn’t match the one used at authorize |
Re-prompt user; ensure URIs are byte-for-byte identical |
Authorize-endpoint errors (returned to the frontend, NOT redirected):
| HTTP status | Body | When | What to do |
|---|---|---|---|
| 400 | { "error": { "code": "invalid_redirect_uri", "message": "..." } } |
redirect_uri not in app’s registered list |
Add the URI in /settings/developer-apps |
| 400 | { "error": { "code": "invalid_scope", "message": "Scope X not in app's scopes_requested" } } |
Requested a scope outside scopes_requested |
Edit the app to widen scopes_requested |
| 404 | { "error": { "code": "invalid_client", "message": "Unknown client_id or app suspended" } } |
client_id unknown or app status ≠ active |
Verify client_id; check the app isn’t suspended |
API-endpoint errors when using the access token:
| HTTP status | Body | When | What to do |
|---|---|---|---|
| 401 | { "error": { "code": "unauthorized" } } |
Token expired, revoked, or malformed | Re-prompt user via /oauth/authorize |
| 403 | { "error": { "code": "insufficient_scope", "message": "Missing required scope: workflows:execute", "missingScope": "workflows:execute" } } |
Token doesn’t carry the scope the route requires | Re-do /oauth/authorize with broader scope — the existing authorization will be widened |
The SDK surfaces the 403 case as ForbiddenError with a typed
missingScope field:
import { ForbiddenError } from "@nodaro/sdk"
try {
await userClient.workflows.run(workflowId)
} catch (err) {
if (err instanceof ForbiddenError && err.missingScope) {
// Redirect the user back to /oauth/authorize with the broader scope set.
redirectToConsent({ scopes: [...currentScopes, err.missingScope] })
return
}
throw err
}
11. Security checklist
Before going to production, verify each item:
- HTTPS everywhere. Nodaro instance, your app, and every
redirect URI must be
https://.http://localhostis OK only for local dev. stateparameter is generated per-authorization, stored in a session cookie, and verified on the callback.client_secretis server-side only. Never bundled into a browser app, never logged, never echoed in error messages.- Secret rotation is documented in your runbook. Rotate at least once a year, or immediately on any suspicion of leak.
- Redirect URIs are limited to your own domains. No catch-all patterns. (Nodaro doesn’t support wildcards anyway, but the principle is to register only what you actually use.)
- Scope minimization. Don’t request scopes you don’t use yet — add them when you build the feature that needs them.
- Token revocation on logout. When a user signs out of your
app, call
client.oauth.revoke()so their Nodaro token can’t be replayed. ForbiddenError(missingScope)handling. Catch the SDK error and offer the user a one-click re-consent flow, not a generic “permission denied” page.
12. Testing locally
The fastest end-to-end test loop:
-
Run a local Nodaro via the community-edition docker-compose (see Community Edition Quickstart). This gives you
http://localhost:3000— Caddy serves the frontend and proxies all/v1/*API requests to the backend on its internal port. - Register an app at
http://localhost:3000/settings/developer-apps:- Redirect URI:
http://localhost:8080/cb - Scopes:
workflows:read,workflows:execute,jobs:read
- Redirect URI:
-
Run a tiny callback server on port 8080:
import express from "express" import { createClient, StaticTokenAuth } from "@nodaro/sdk" const app = express() app.get("/cb", async (req, res) => { const client = createClient({ baseUrl: "http://localhost:3000", 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) -
Hit the authorize URL in your browser:
http://localhost:3000/oauth/authorize? client_id=app_...& redirect_uri=http://localhost:8080/cb& response_type=code& scope=workflows:read+workflows:execute& state=test123 -
Click Allow. The callback fires; the tiny server echoes the token JSON to the browser.
- Use the token to call a real route — for example,
GET /v1/projects/<id>/workflows. Confirm you get 200 with data.
13. Differences from RFC 6749
Where Nodaro intentionally simplifies or extends the standard:
- PKCE supported (RFC 7636). Public clients (mobile, SPA, CLI tools)
that cannot securely hold a
client_secretshould use PKCE instead: includecode_challenge(SHA-256 of your verifier, base64url-encoded) andcode_challenge_method=S256on the authorize redirect, then sendcode_verifierat token exchange in place ofclient_secret. The server performs timing-safeSHA256(code_verifier) == code_challengeverification. Confidential clients (server-to-server with a storedclient_secret) may use either path; if bothcode_challengeandclient_secretare present the server verifies both. - No refresh tokens. Access tokens last 90 days. When they expire, re-prompt the user. The tradeoff is simplicity (one token type, one storage path, one expiry policy) at the cost of a consent-screen interruption every quarter.
POST /v1/oauth/revokealways returns 200 per RFC 7009 — including for tokens that don’t exist. This prevents an attacker from probing whether a guessed token string is valid.- Custom token format. RFC 6749 doesn’t mandate a format for
bearer tokens; Nodaro uses
ndr_app_<64hex>so leaked tokens are trivially greppable in logs and source repos. Token type is stillBearer. - Custom code format. Authorization codes are
ndr_code_<48hex>, 10-minute TTL, single-use. - Strict redirect-URI matching. No wildcards, no path-prefix matching — only byte-for-byte equality against the registered list. This is a deliberate hardening over the RFC’s looser language.
- Token endpoint accepts both
application/jsonandapplication/x-www-form-urlencoded. RFC 6749 §4.1.3 specifies only form-encoding; Nodaro additionally accepts JSON (consistent with the rest of the API and easier for SDK callers). Standard OAuth clients that post form-encoded bodies work unchanged —client_secret_postis advertised in the discovery metadata.
For the SDK methods used in this doc, see the OAuth section of the SDK Reference. For the broader API, see SDK Quickstart.