An app that other people install can sign each person in with their own plnnrly account. The person approves the app once, and the app gets an access token that it renews itself. Nobody copies a token, and the app never sees a password.
This guide walks through every step and ends with a complete example you can run.
Who this is for
- Desktop, command-line and native apps that other people install. They receive the sign-in on a loopback address on the person's own computer.
- Not web apps with their own callback address. An app cannot register an
httpsaddress of its own; see redirect addresses. - Not apps that run in the browser. The token endpoint accepts no cross-origin requests, so a browser app cannot finish the sign-in.
- Not your own scripts and servers. For those, a personal access token is simpler.
Before you start
- The organisation has the REST API switched on. It is on unless an administrator switched it off; see For administrators.
- The person who signs in holds the Manage API tokens permission. In a new organisation the Admin role has it. See who may create tokens.
- Every address in this guide is on the organisation's own host,
https://<your-url>.<your-url>is the address people sign in to plnnrly on, such asacme.plnnrly.com, or the organisation's own domain. Each organisation is separate, so your app asks the person for this address first.
1. Discover the endpoints
curl "https://<your-url>/.well-known/oauth-authorization-server"The answer names everything the rest of this guide uses:
| Field | Value |
|---|---|
authorization_endpoint | https://<your-url>: where you send the person's browser. |
token_endpoint | https://<your-url>: where you exchange and refresh tokens. |
registration_endpoint | https://<your-url>: where your app registers itself. |
revocation_endpoint | https://<your-url>: where you sign out. |
code_challenge_methods_supported | ["S256"] |
scopes_supported | Every scope you may ask for. |
https://<your-url>/.well-known/oauth-protected-resource/api/v2 describes the API itself and lists the scopes it accepts.
A 404 here means the organisation has the REST API switched off.
2. Register your app
curl "https://<your-url>/oauth/register" \
-X POST \
-H "Content-Type: application/json" \
-d '{
"client_name": "Acme Timesheets",
"redirect_uris": ["http://127.0.0.1:53682/callback"]
}'The answer is 201:
{
"client_id": "9e3f2c71-5b8a-4d06-a1f4-7c2e9b5d8a13",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"redirect_uris": ["http://127.0.0.1:53682/callback"],
"scope": "mcp:use",
"token_endpoint_auth_method": "none"
}
- Your app is a public client. It gets no client secret, and
token_endpoint_auth_methodisnone. - The client name is what people see.
client_nameshows on the consent screen and under Connected apps. Use the name they know your app by. - Ignore the scope in the answer. Registration always answers with
"scope": "mcp:use", whatever your app will ask for. You choose the scopes when you send the person to sign in. - A refused redirect address answers
400with"error": "invalid_redirect_uri". - Registration is limited to 5 requests per minute per IP address and 30 per minute per organisation. Register once per host and keep the
client_id.
Redirect addresses
After the person approves, their browser goes to your redirect address with a code. Listen on a loopback address while the sign-in runs.
| Redirect address | Accepted |
|---|---|
http://127.0.0.1: | Yes. The port may differ at every sign-in, so listen on any free port. |
http://[:: | Yes, in the same way. |
http://localhost: | Only with exactly the port you registered. |
A custom scheme, such as myapp://callback | No. The only custom scheme accepted is Cursor's own. |
An https address of your own | No. The only https addresses accepted are those of the AI clients plnnrly supports. |
On 127.0.0.1 and [::1] everything except the port must match the address you registered. On any other address the whole address must match exactly. A refused address answers invalid_redirect_uri when you register. An address that does not match during sign-in answers invalid_client.
Keep the client_id per host
A client_id works on one organisation's host only. Store it per host, and register separately for each organisation your app connects to.
A registration is deleted overnight once it is more than a day old and nothing issued to it is left. That happens to:
- an app that never completed a sign-in
- an app that every person disconnected
- an app nobody used for a month: after 30 idle days its refresh token stops working, and from about day 32 the registration is gone too
When the token endpoint answers invalid_client, or the browser shows invalid_client during sign-in, register again and start a new sign-in.
3. Send the person to sign in
Create a random code_verifier of 43 to 128 characters and keep it until step 4. The code_challenge is its SHA-256 hash, base64url-encoded without padding. Then open the person's browser at the authorization endpoint with these parameters:
| Parameter | Value |
|---|---|
response_type | code |
client_id | From registration. |
redirect_uri | The address you listen on. |
scope | The scopes you need, separated by spaces, such as api:. |
state | A random value. Check that it comes back unchanged. |
code_challenge | The hash of your code_verifier. |
code_challenge_method | S256 |
PKCE is required.
Always send the scope parameter. Without scope the sign-in asks for mcp:use, the scope for AI assistants, which cannot call /api/v2. While MCP is switched off, a sign-in without scope answers 404.
Ask for api:read and api:write, or narrow them to a resource, such as api:read:projects and api:write:projects. scopes_supported lists every scope you may ask for, and Scopes and permissions explains what each one covers. There is no destroy class over OAuth: asking for api:destroy fails with invalid_scope.
What the person sees
If the person is not signed in to plnnrly on that host, they sign in first. Then the consent screen shows:
- the title "Connect Acme Timesheets", and "Acme Timesheets wants to access Acme on your behalf."
- Signed in as, with their name and email address
- the scopes you asked for, such as "Read: All resources" or "Write: Projects"
- "It acts as you: it can see and change what your account can, and nothing more."
- a warning that the app can also delete records, when you ask for
mcp:useand the organisation allows destructive actions - the host their browser goes back to, such as "After you approve you are sent back to 127.0.0.1."
The buttons are Approve and Cancel. Approve sends the browser to your redirect address with code and state. Cancel sends it there with error=access_denied.
A person who already approved the same scopes for your app, and still holds a live access token from it, is sent straight back without seeing the screen.
The sign-in is refused when:
- the person's role lacks Manage API tokens:
403"Your role does not allow connecting apps to the REST API." - the person is in a plnnrly support session:
403 - you ask for
mcp:usewhile MCP is switched off for the organisation:404
4. Exchange the code
curl "https://<your-url>/oauth/token" \
-X POST \
-d grant_type=authorization_code \
-d client_id=<client_id> \
-d redirect_uri=http://127.0.0.1:53682/callback \
-d code_verifier=<code_verifier> \
-d code=<code>The token endpoint takes form-encoded bodies. It answers:
{
"token_type": "Bearer",
"expires_in": 3600,
"access_token": "<access_token>",
"refresh_token": "<refresh_token>"
}
Send the access token as the bearer token on /api/v2. It lasts one hour.
The token endpoint allows 120 requests per minute per organisation and IP address, enough for many apps behind one office connection. The discovery documents and the revocation endpoint allow 30 requests per minute per organisation and IP address.
5. Refresh
Once the access token has expired, /api/v2 answers 401 unauthenticated. Trade the refresh token for a new pair:
curl "https://<your-url>/oauth/token" \
-X POST \
-d grant_type=refresh_token \
-d client_id=<client_id> \
-d refresh_token=<refresh_token>- Each refresh token works once. The answer carries a new one, and the old one stops working, so store the new one straight away.
- A refresh token lasts 30 days, counted from the last refresh. An app used at least once every 30 days stays signed in.
- After 30 idle days the refresh answers
invalid_grant, and the person signs in again.
6. Sign out
curl "https://<your-url>/oauth/revoke" \
-X POST \
-d token=<refresh_token> \
-d token_type_hint=refresh_token \
-d client_id=<client_id>Revoking either token disconnects the app for that person: every token it holds for them stops working. The endpoint answers 200 even for a token it does not recognise, as RFC 7009 requires.
People can also disconnect an app themselves: they open API access in the user menu and choose Revoke next to the app under Connected apps. See connected apps.
What an OAuth app cannot do
- Delete anything. Every
DELETEneeds the destroy class, which OAuth never offers. Deleting needs a personal access token with Delete. - Upload file contents. The upload step accepts personal access tokens only, so an OAuth token gets
401there. See Files: upload and download. - Reach more over MCP. If the same sign-in also asks for
mcp:use, the app is held to itsapi:scopes there too. See MCP access.
The person's Manage API tokens permission is checked on every request. If an administrator takes it away, the app's calls answer 403 insufficient-permission until the permission is given back.
A complete example
This script signs a person in and prints their name. It opens a loopback listener on a free port, registers, creates the PKCE pair, opens the browser, exchanges the code and calls GET/me.
Save the JavaScript as sign-in.mjs and run it with Node 20 or later, with no packages to install. Save the Python as sign_in.py and run it with Python 3.10 or later after pip install httpx. Pass the address to sign in to:
node sign-in.mjs acme.plnnrly.com
python sign_in.py acme.plnnrly.com
import { spawn } from 'node:child_process'
import { createHash, randomBytes } from 'node:crypto'
import { createServer } from 'node:http'
const host = process.argv[2]
if (!host) {
console.error('Usage: node sign-in.mjs <your-url>')
process.exit(1)
}
const base = `https://${host}`
const scope = 'api:read'
async function call(url, init = {}) {
const response = await fetch(url, { ...init, headers: { Accept: 'application/json', ...init.headers } })
const text = await response.text()
if (!response.ok) {
throw new Error(`${response.status} from ${url}: ${text}`)
}
return JSON.parse(text)
}
function openBrowser(url) {
const [command, args] = {
darwin: ['open', [url]],
win32: ['rundll32', ['url.dll,FileProtocolHandler', url]],
}[process.platform] ?? ['xdg-open', [url]]
spawn(command, args, { stdio: 'ignore', detached: true }).on('error', () => {}).unref()
}
// 1. Listen on a free port on the loopback address.
const server = createServer()
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
const redirectUri = `http://127.0.0.1:${server.address().port}/callback`
// 2. Discover the endpoints.
const metadata = await call(`${base}/.well-known/oauth-authorization-server`)
// 3. Register. A real app stores client_id per host and registers again on invalid_client.
const { client_id: clientId } = await call(metadata.registration_endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ client_name: 'Acme Timesheets', redirect_uris: [redirectUri] }),
})
// 4. Create the PKCE pair and send the person to sign in.
const verifier = randomBytes(32).toString('base64url')
const challenge = createHash('sha256').update(verifier).digest('base64url')
const state = randomBytes(16).toString('base64url')
const authorizeUrl = new URL(metadata.authorization_endpoint)
authorizeUrl.search = new URLSearchParams({
response_type: 'code',
client_id: clientId,
redirect_uri: redirectUri,
scope,
state,
code_challenge: challenge,
code_challenge_method: 'S256',
})
const callback = new Promise((resolve) => {
server.on('request', (request, response) => {
const url = new URL(request.url, redirectUri)
if (url.pathname !== '/callback') {
response.writeHead(404).end()
return
}
response.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' })
response.end('You can close this window and go back to the app.')
resolve(url.searchParams)
})
})
console.log(`Opening your browser. If it does not open, visit:\n${authorizeUrl}`)
openBrowser(authorizeUrl.href)
const params = await callback
server.close()
if (params.get('state') !== state) {
throw new Error('The state does not match. Start the sign-in again.')
}
if (params.has('error')) {
throw new Error(`The sign-in was refused: ${params.get('error')}`)
}
// 5. Exchange the code for tokens.
const tokens = await call(metadata.token_endpoint, {
method: 'POST',
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: clientId,
redirect_uri: redirectUri,
code_verifier: verifier,
code: params.get('code'),
}),
})
// 6. Call the API as the person who signed in.
const me = await call(`${base}/api/v2/me`, {
headers: { Authorization: `Bearer ${tokens.access_token}` },
})
console.log(`Signed in as ${me.data.name}`)import base64
import hashlib
import secrets
import sys
import webbrowser
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import parse_qs, urlencode, urlparse
import httpx
if len(sys.argv) != 2:
sys.exit("Usage: python sign_in.py <your-url>")
base = f"https://{sys.argv[1]}"
scope = "api:read"
class Callback(BaseHTTPRequestHandler):
params = None
timeout = 5
def do_GET(self):
url = urlparse(self.path)
if url.path != "/callback":
self.send_response(404)
self.end_headers()
return
Callback.params = {key: values[0] for key, values in parse_qs(url.query).items()}
self.send_response(200)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.end_headers()
self.wfile.write(b"You can close this window and go back to the app.")
def log_message(self, *args):
pass
def call(response):
if response.is_error:
sys.exit(f"{response.status_code} from {response.url}: {response.text}")
return response.json()
# 1. Listen on a free port on the loopback address.
server = HTTPServer(("127.0.0.1", 0), Callback)
redirect_uri = f"http://127.0.0.1:{server.server_port}/callback"
with httpx.Client(headers={"Accept": "application/json"}) as client:
# 2. Discover the endpoints.
metadata = call(client.get(f"{base}/.well-known/oauth-authorization-server"))
# 3. Register. A real app stores client_id per host and registers again on invalid_client.
client_id = call(client.post(
metadata["registration_endpoint"],
json={"client_name": "Acme Timesheets", "redirect_uris": [redirect_uri]},
))["client_id"]
# 4. Create the PKCE pair and send the person to sign in.
verifier = secrets.token_urlsafe(32)
digest = hashlib.sha256(verifier.encode()).digest()
challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
state = secrets.token_urlsafe(16)
authorize_url = metadata["authorization_endpoint"] + "?" + urlencode({
"response_type": "code",
"client_id": client_id,
"redirect_uri": redirect_uri,
"scope": scope,
"state": state,
"code_challenge": challenge,
"code_challenge_method": "S256",
})
print(f"Opening your browser. If it does not open, visit:\n{authorize_url}")
webbrowser.open(authorize_url)
while Callback.params is None:
server.handle_request()
server.server_close()
params = Callback.params
if params.get("state") != state:
sys.exit("The state does not match. Start the sign-in again.")
if "error" in params:
sys.exit(f"The sign-in was refused: {params['error']}")
# 5. Exchange the code for tokens.
tokens = call(client.post(metadata["token_endpoint"], data={
"grant_type": "authorization_code",
"client_id": client_id,
"redirect_uri": redirect_uri,
"code_verifier": verifier,
"code": params["code"],
}))
# 6. Call the API as the person who signed in.
me = call(client.get(
f"{base}/api/v2/me",
headers={"Authorization": f"Bearer {tokens['access_token']}"},
))
print(f"Signed in as {me['data']['name']}")A real app does three things this script leaves out. It stores the client_id per host instead of registering on every run. It keeps the refresh token in the system's credential store and refreshes when a call answers 401. And it asks for only the scopes it needs.
Related
- Authentication: how tokens are sent and the order of checks.
- Scopes and permissions: what each scope covers.
- Limits at a glance: every limit in one place, the OAuth ones included.