# Sign in with OAuth

Let people sign in to your desktop or command-line app with their plnnrly account, instead of pasting a token. OAuth 2.0 with PKCE, step by step.

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](#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 `https` address of its own; see [redirect addresses](#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](https://developers.plnnrly.com/en/docs/api/v2/guides/tokens/) 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](https://developers.plnnrly.com/en/docs/administration/#switches).
- 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](https://developers.plnnrly.com/en/docs/api/v2/guides/tokens/#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 as `acme.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

```bash
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>/oauth/authorize`: where you send the person's browser. |
| `token_endpoint`                   | `https://<your-url>/oauth/token`: where you exchange and refresh tokens.   |
| `registration_endpoint`            | `https://<your-url>/oauth/register`: where your app registers itself.      |
| `revocation_endpoint`              | `https://<your-url>/oauth/revoke`: 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

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

```json
{
  "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_method` is `none`.
- **The client name is what people see.** `client_name` shows 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 `400` with `"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:{port}/path`              | Yes. The port may differ at every sign-in, so listen on any free port.                |
| `http://[::1]:{port}/path`                  | Yes, in the same way.                                                                 |
| `http://localhost:{port}/path`              | 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:read api:write`. |
| `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](https://developers.plnnrly.com/en/docs/api/v2/guides/scopes/) 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:use` and 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:use` while MCP is switched off for the organisation: `404`

## 4. Exchange the code

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

```json
{
  "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](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#unauthenticated). Trade the refresh token for a new pair:

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

```bash
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](https://www.rfc-editor.org/rfc/rfc7009) 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](https://developers.plnnrly.com/en/docs/api/v2/guides/tokens/#connected-apps).

## What an OAuth app cannot do

- **Delete anything.** Every `DELETE` needs 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 `401` there. See [Files: upload and download](https://developers.plnnrly.com/en/docs/api/v2/guides/uploads/).
- **Reach more over MCP.** If the same sign-in also asks for `mcp:use`, the app is held to its `api:` scopes there too. See [MCP access](https://developers.plnnrly.com/en/docs/api/v2/guides/authentication/#mcp-access-is-a-different-family).

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](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#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](https://developers.plnnrly.com/en/docs/api/v2/me/get/).

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:

```bash
node sign-in.mjs acme.plnnrly.com
python sign_in.py acme.plnnrly.com
```

```js [JavaScript]
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}`)
```

```python [Python]
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](#refresh) when a call answers `401`. And it asks for only the scopes it needs.

## Related

- [Authentication](https://developers.plnnrly.com/en/docs/api/v2/guides/authentication/): how tokens are sent and the order of checks.
- [Scopes and permissions](https://developers.plnnrly.com/en/docs/api/v2/guides/scopes/): what each scope covers.
- [Limits at a glance](https://developers.plnnrly.com/en/docs/api/v2/guides/limits/): every limit in one place, the OAuth ones included.
