Sign in with Plotwork
How to let the people of your app sign in with their Plotwork account and call the Plotwork API as themselves, with no API key pasted anywhere. It is OAuth 2.1 with PKCE, for developers who build a company portal, a product that inserts or publishes Plotwork diagrams, or a browser app with no server.
TL;DR
| Question | Answer |
|---|---|
| Who is it for? | An app whose own users have Plotwork accounts: a developer portal such as Backstage that shows each engineer their organisation's private library, a product that lets its users insert or publish diagrams, or a browser app. |
| When do I use something else? | A public website whose visitors have no Plotwork account shows diagrams with a stable link or an embed link. CI and scripts use an API key or a trusted publisher. |
| How do I start? | Register the app in the portal under Developers, Developer apps (or an organisation's Developers, Apps). You get a client id, and a client secret for an app with a server. |
| What can an app ask for? | diagrams:use, diagrams:publish, library:read, profile and email. The person sees each one in plain words and approves once. |
| How long do tokens last? | An access token an hour; a refresh token 30 days, used once, and replaced at each refresh. A refresh token used twice ends the connection. |
| Is there a library? | Yes: @plotwork.official/sdk/oauth for TypeScript and plotwork.oauth for Python, and the viewer shows private diagrams with the person's token. See SDKs. |
| Which plan? | Every plan may register and approve apps. An app's calls spend the plan of the person or organisation that approved it. |
| Can an organisation control it? | Its owner and admins see and end every app connected to it. On Enterprise, "Only approved apps can connect" keeps it to a list. |
When to use it
| You want to | Use |
|---|---|
| Show a person their own diagrams, private ones included, in your app | Sign in with Plotwork with library:read |
| Let a person draw or publish from your product, on their own account and plan | Sign in with Plotwork with diagrams:use or diagrams:publish |
| Show a published diagram to anyone on a public page | A stable link or an embed link, no sign-in |
| Draw diagrams from CI, a script or a server job | An API key, or a trusted publisher in CI |
A token from Sign in with Plotwork belongs to one person and acts only within what they approved. An API key belongs to whoever made it and must stay on a server.
Register an app
Open the portal (opens in a new tab) and choose Developers, Developer apps, for an app of your own. For an app that belongs to an organisation, its owner or an admin chooses the organisation's Developers, Apps; such an app can be limited to the organisation's members.
| Field | Rule |
|---|---|
| Name | 3 to 60 characters. People see it on the consent page as a claim, beside where the app sends them back. |
| Homepage | Optional, https. |
| Redirect addresses | One to ten. https, or http on localhost, 127.0.0.1 or [::1] for development and desktop tools. No fragment, no user name or password, at most 2048 characters. A sign-in must name one exactly, port and path included. |
| Type | Confidential, for an app with a server: a secret (pws_...) is shown once, and you can make a new one. Public, for a browser or desktop app with no server: no secret. The type cannot change later. |
| Members only | For an organisation's app: only its members can connect it, and only to that organisation. |
An account or an organisation can register 25 apps. The portal shows the client id with a Copy button, the authorize, token and metadata addresses, and the scopes.
The flow
sequence "How an app signs a person in" desc="Your app sends the person to Plotwork, the person approves it in the portal, and your app trades the code for tokens that call the API as that person."
actor app client "Your app"actor browser client "Browser"actor api service "Plotwork API"actor portal client "Plotwork portal"
app -> browser : "Redirect to /oauth/authorize with PKCE"browser -> api : "GET /oauth/authorize"api --> browser : "Redirect to the consent page"browser -> portal : "The person approves"portal --> browser : "Back to your redirect address"browser --> app : "code, state and iss"app -> api : "POST /oauth/token with code and verifier"api --> app : "Access and refresh tokens"app -> api : "GET /v1/library with Bearer token"api --> app : "The person's diagrams"Participants:
- Your app, client
- Browser, client
- Plotwork API, service
- Plotwork portal, client
Steps:
-
- Your app to Browser: Redirect to /oauth/authorize with PKCE
-
- Browser to Plotwork API: GET /oauth/authorize
-
- Plotwork API to Browser (dashed): Redirect to the consent page
-
- Browser to Plotwork portal: The person approves
-
- Plotwork portal to Browser (dashed): Back to your redirect address
-
- Browser to Your app (dashed): code, state and iss
-
- Your app to Plotwork API: POST /oauth/token with code and verifier
-
- Plotwork API to Your app (dashed): Access and refresh tokens
-
- Your app to Plotwork API: GET /v1/library with Bearer token
-
- Plotwork API to Your app (dashed): The person's diagrams
| Step | What happens |
|---|---|
| 1. Authorize | Your app makes a PKCE verifier and its S256 challenge and a random state, keeps them, and sends the browser to https://api.plotwork.dev/oauth/authorize. |
| 2. Approve | Plotwork checks the app and the address, and shows the consent page. A person who is signed out signs in first. They see the app's name, where it sends them back, each scope, and "Connect as": their own account or an organisation. |
| 3. Code | The browser comes back to your redirect address with code, state and iss. The code works once, for 60 seconds. |
| 4. Exchange | Your app checks state and iss, then posts the code and the verifier to /oauth/token and gets an access token and a refresh token. |
| 5. Call | Every /v1 call sends Authorization: Bearer pwa_.... |
| 6. Refresh | Before the hour is up, your app trades the refresh token for a new pair. |
A sign-in link is good for ten minutes and is answered once. A person who opens it again is told it was used, and starts again from your app.
Scopes
| Scope | Lets the app | Routes |
|---|---|---|
diagrams:use | Validate, draw, render and import: computation on what the app sends | Every /v1 route that computes |
diagrams:publish | Publish a diagram on the account or organisation | POST /v1/diagrams/publish |
library:read | List and show the diagrams the account can see, private ones included, under the organisation's folder rules | GET /v1/library, GET /v1/library/{id}.svg |
profile | Read the account's id and name | GET /v1/me |
email | Add the email address to GET /v1/me (with profile) | GET /v1/me |
Ask only for what your feature needs. A route outside the approved scopes answers 403 oauth.insufficient_scope with the scope in details.scope. A token never opens the live whiteboards, the portal's own routes, or an organisation's settings.
Tokens
| Token | Looks like | Lasts | Notes |
|---|---|---|---|
| Access token | pwa_ and 64 hex characters | One hour (expires_in is 3600) | Send it as Authorization: Bearer. |
| Refresh token | pwr_ and 64 hex characters | 30 days, once | Each refresh answers a new one with a fresh 30 days, and the old one stops working. |
| Code | pwc_ and 64 hex characters | 60 seconds, once | Bound to your client, the redirect address and the PKCE challenge. |
A refresh token or a code that is used a second time ends the whole connection, because only a stolen copy is used twice. The person then has to approve the app again. Plotwork stores every token, code and secret only as a SHA-256 hash, so ending a connection ends its tokens at their next call.
Approving the same app again for the same account replaces the earlier connection.
Endpoints
The issuer is https://api.plotwork.dev. The metadata at /.well-known/oauth-authorization-server lists the endpoints, PKCE S256 only, the client authentication methods (client_secret_basic, client_secret_post and none), the five scopes, and authorization_response_iss_parameter_supported.
Authorize
GET /oauth/authorize, in the browser.
| Parameter | Value |
|---|---|
response_type | code |
client_id | Your app's client id |
redirect_uri | One of the app's registered addresses, exactly. May be left out when the app has one. |
scope | One or more scopes, separated by spaces |
state | A random value you keep and check, at most 1024 characters |
code_challenge | The base64url SHA-256 of the verifier, 43 characters |
code_challenge_method | S256 |
An unknown client_id is 400 oauth.invalid_client, and an address the app did not register 400 oauth.invalid_redirect_uri, shown on the API host and never sent to the address. Every other mistake goes back to your address with error (unsupported_response_type, invalid_request or invalid_scope), error_description, state and iss. A person who says no comes back with error=access_denied.
Token
POST /oauth/token, form-encoded, from your server or the browser app.
| Grant | Fields |
|---|---|
authorization_code | grant_type, code, redirect_uri (the same one), code_verifier, client_id |
refresh_token | grant_type, refresh_token, client_id, and optional scope naming only scopes the connection holds |
A confidential app sends its secret by HTTP Basic or as client_secret in the form, once. The answer is access_token, token_type (Bearer), expires_in, refresh_token and scope, with cache-control: no-store. A refusal is OAuth's own shape:
{ "error": "invalid_grant", "error_description": "The code is not valid, has expired, or was already used."}invalid_client is 401; invalid_request, invalid_grant, invalid_scope and unsupported_grant_type are 400. The endpoint answers any origin, without credentials, and is rate-limited by address.
Revoke
POST /oauth/revoke, form-encoded, with token and the same client authentication. An access token ends; a refresh token ends the whole connection, which is how an app signs a person out. An unknown token is answered 200 as well.
Calls
GET /v1/me answers who the token acts as. GET /v1/library answers a page of diagrams (limit, cursor, folder), and GET /v1/library/{id}.svg one picture with an ETag. Every other /v1 operation is in the API reference.
An app with a server
A confidential app keeps the secret and the tokens on its server; the browser never sees a token. With the TypeScript SDK, as Next.js route handlers:
// app/auth/plotwork/route.ts: send the person to Plotwork.import { authorizeUrl, createPkce } from '@plotwork.official/sdk/oauth'import { cookies } from 'next/headers'import { redirect } from 'next/navigation'
const clientId = process.env.PLOTWORK_CLIENT_ID ?? ''const redirectUri = 'https://example.com/auth/plotwork/callback'
export async function GET() { const pkce = await createPkce() const { url, state } = authorizeUrl({ clientId, redirectUri, scopes: ['library:read', 'profile'], codeChallenge: pkce.challenge }) const jar = await cookies() jar.set('plotwork_sign_in', JSON.stringify({ state, verifier: pkce.verifier }), { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 600 }) redirect(url)}// app/auth/plotwork/callback/route.ts: trade the code for tokens.import { exchangeCode, parseRedirect } from '@plotwork.official/sdk/oauth'import { cookies } from 'next/headers'import { redirect } from 'next/navigation'
const clientId = process.env.PLOTWORK_CLIENT_ID ?? ''const clientSecret = process.env.PLOTWORK_CLIENT_SECRETconst redirectUri = 'https://example.com/auth/plotwork/callback'
export async function GET(request: Request) { const jar = await cookies() const { state, verifier } = JSON.parse(jar.get('plotwork_sign_in')?.value ?? '{}') jar.delete('plotwork_sign_in') const code = parseRedirect(request.url, state) // throws on a foreign state, another iss, or error= const tokens = await exchangeCode({ clientId, clientSecret, code, redirectUri, codeVerifier: verifier }) await saveToSession(tokens) // your server-side session store redirect('/')}Then, for each request:
import { Plotwork } from '@plotwork.official/sdk'import { withRefresh } from '@plotwork.official/sdk/oauth'
const plotwork = new Plotwork({ accessToken: withRefresh(session.tokens, { clientId, clientSecret, onRefresh: (tokens) => saveToSession(tokens) }),})const { items } = await plotwork.library.list()withRefresh refreshes a minute before the access token ends, once however many calls ask, and hands each new pair to onRefresh to save. Use one per connection. Every refusal throws PlotworkOAuthError with OAuth's error, its description, and the HTTP status.
| Helper | What it does |
|---|---|
createPkce() | A verifier and its S256 challenge |
authorizeUrl(...) | The address to send the browser to, and its state |
parseRedirect(url, state) | The code, after checking state, iss and error |
exchangeCode(...) | The token set |
refreshToken(...) | A new token set |
revokeToken(...) | Signs the person out |
withRefresh(tokens, ...) | An access token function that refreshes on its own |
A browser app with no server
A public app has no secret. The tokens live in the page's memory, and the refresh token is replaced at every use.
import { Plotwork } from '@plotwork.official/sdk'import { authorizeUrl, createPkce, exchangeCode, parseRedirect, withRefresh } from '@plotwork.official/sdk/oauth'
const clientId = 'your client id'const redirectUri = `${location.origin}/callback`
export async function signIn(): Promise<void> { const pkce = await createPkce() const { url, state } = authorizeUrl({ clientId, redirectUri, scopes: ['library:read'], codeChallenge: pkce.challenge }) sessionStorage.setItem('plotwork-sign-in', JSON.stringify({ state, verifier: pkce.verifier })) location.assign(url)}
// On the page at redirectUri:export async function finishSignIn(): Promise<Plotwork> { const { state, verifier } = JSON.parse(sessionStorage.getItem('plotwork-sign-in') ?? '{}') sessionStorage.removeItem('plotwork-sign-in') const code = parseRedirect(location.href, state) const tokens = await exchangeCode({ clientId, code, redirectUri, codeVerifier: verifier }) return new Plotwork({ accessToken: withRefresh(tokens, { clientId, onRefresh: () => undefined }) })}/v1, /oauth/token and /oauth/revoke answer any origin without credentials, so the page calls them from its own origin. A desktop or command line tool works the same way with a loopback redirect address such as http://127.0.0.1:8765/callback, registered with its port.
Python
plotwork.oauth has the same helpers for a server app, on the standard library alone:
import os
from plotwork import Plotworkfrom plotwork.oauth import authorize_url, create_pkce, exchange_code, parse_redirect, with_refresh
CLIENT_ID = os.environ["PLOTWORK_CLIENT_ID"]CLIENT_SECRET = os.environ["PLOTWORK_CLIENT_SECRET"]REDIRECT_URI = "https://example.com/auth/plotwork/callback"
def start(session): pkce = create_pkce() request = authorize_url(client_id=CLIENT_ID, redirect_uri=REDIRECT_URI, scopes=["library:read", "profile"], code_challenge=pkce.challenge) session["plotwork_sign_in"] = {"state": request.state, "verifier": pkce.verifier} return request.url # redirect the browser here
def callback(session, url): saved = session.pop("plotwork_sign_in") code = parse_redirect(url, saved["state"]) session["plotwork_tokens"] = exchange_code( client_id=CLIENT_ID, client_secret=CLIENT_SECRET, code=code, redirect_uri=REDIRECT_URI, code_verifier=saved["verifier"] )
def client_for(session): def save(tokens): session["plotwork_tokens"] = tokens
token = with_refresh(session["plotwork_tokens"], client_id=CLIENT_ID, client_secret=CLIENT_SECRET, on_refresh=save) return Plotwork(access_token=token)client_for(session).library.list() then lists the person's diagrams. A refusal raises PlotworkOAuthError.
Show private diagrams
With library:read, the viewer shows any diagram the person can see, private ones included, from /v1/library/{id}.svg with their token, and <plotwork-library> lets them pick one.
import { PlotworkDiagram, PlotworkLibrary } from '@plotwork.official/viewer/react'import { useState } from 'react'
export function Picker({ token }: { token: () => Promise<string> }) { const [picture, setPicture] = useState<string | null>(null) return ( <> <PlotworkLibrary token={token} onSelect={({ pictureUrl }) => setPicture(pictureUrl)} /> {picture !== null && <PlotworkDiagram src={picture} token={token} live />} </> )}The viewer sends the token only to an https address (or http on localhost), never with a cookie. A 401 is the signed-out event, and setting a fresh token loads the picture again. Show a private picture only to the person who signed in, never at a public address.
Organisations
| Topic | What happens |
|---|---|
| Connect as | The consent page offers the person's own account and each organisation where they may publish and meet its single sign-on rule. A connection to an organisation reads its library and spends its plan. |
| Members only | An organisation's app registered for members only can be connected by its members, to that organisation alone. |
| Allowed apps | On Enterprise, an owner or admin can turn on "Only approved apps can connect" in the organisation's Settings, Security and access, Allowed apps, and list up to 50 client ids. The organisation's own apps are always allowed. An app off the list cannot be connected to the organisation (403 oauth.app_not_allowed), and taking one off ends its connections. |
| What admins see | The organisation's Developers, Apps shows its own apps and every app connected to it, who connected each, and Disconnect. |
| Audit events | orgs.app.connect and orgs.app.disconnect (reason disconnected, replaced, not_allowed, member_left, revoked or reused), orgs.app.create, orgs.app.update, orgs.app.delete, orgs.app.rotate, and orgs.settings.allowed_apps, in the audit log. Never a token. |
| Call history | An app's calls to an organisation are kept in its call history like calls made with its keys. |
A connection to an organisation ends when the member who made it leaves or becomes a reader, or when the organisation is deleted.
Disconnect
A person ends a connection in the portal's Settings, under Connected apps. An organisation's owner and admins end any connection to the organisation from its Apps section. Deleting an app ends every connection to it. Your app signs a person out by revoking its refresh token. After any of these, the next call answers 401 oauth.invalid_token and the next refresh invalid_grant: delete what you hold for that person and offer the sign-in button again.
Limits and plans
| Rule | Detail |
|---|---|
| Who may register | Every plan, up to 25 apps an account or organisation |
| Who may approve | Every plan |
| What a call spends | The daily allowance and rate limits of the person or organisation that approved the app, as Plans and billing describes. A Free account's connection gets Free limits. |
/v1/me and the library | Open on every plan, metered and rate-limited like any call |
| The token endpoint | Rate-limited by address |
Security
| Do | Why |
|---|---|
| Treat the app name on the consent page as a claim | Anyone can register an app with any name. People see where it sends them back beside it; so do you, when you approve one. |
| Keep a client secret on a server | Never put a secret, or a confidential app's refresh token, in a browser, a mobile app or a repository. A browser app registers as public. |
Use PKCE and state on every sign-in, and check iss | They stop a code from being stolen or injected. parseRedirect checks all three. |
| Keep tokens out of logs and addresses | A token in a log or a query string can be read by anyone who sees it. |
| Make a new secret if one was seen | The old one stops working at once. |
Building an app means accepting the developer terms: use what you receive only for the person's request, never sell it, and delete it when they disconnect.
The button
Start the sign-in from the Sign in with Plotwork button, for a light page or a dark page:
Both files, plotwork-sign-in-on-light.svg and plotwork-sign-in-on-dark.svg, are in the buttons folder of the brand kit (opens in a new tab). Serve a copy from your own site, or link them as below, and show them as they are, 40 pixels tall:
<a class="plotwork-sign-in" href="/auth/plotwork"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://assets.plotwork.dev/brand/buttons/plotwork-sign-in-on-dark.svg"> <img src="https://assets.plotwork.dev/brand/buttons/plotwork-sign-in-on-light.svg" alt="Sign in with Plotwork" width="203" height="40"> </picture></a>Or draw it in your own markup, with the same measurements and colours:
<a class="plotwork-sign-in-button" href="/auth/plotwork"> <svg viewBox="3 3 26 26" width="20" height="20" aria-hidden="true"> <defs> <linearGradient id="plotwork-mark" gradientUnits="userSpaceOnUse" x1="2" y1="3" x2="30" y2="29"> <stop offset="0" stop-color="#c6f04a"/><stop offset="0.55" stop-color="#14b8a6"/><stop offset="1" stop-color="#0f766e"/> </linearGradient> </defs> <path d="M3 3H14A11 11 0 0 1 3 14Z" fill="url(#plotwork-mark)"/> <path d="M29 3V14A11 11 0 0 1 18 3Z" fill="url(#plotwork-mark)"/> <path d="M29 29H18A11 11 0 0 1 29 18Z" fill="url(#plotwork-mark)"/> <path d="M3 29V18A11 11 0 0 1 14 29Z" fill="#0f766e"/> </svg> <span>Sign in with Plotwork</span></a>.plotwork-sign-in-button { display: inline-flex; align-items: center; gap: 10px; height: 40px; padding: 0 16px; border: 1px solid #dcdcdc; border-radius: 8px; background: #ffffff; color: #171717; font: 500 15px/1 'IBM Plex Sans', system-ui, sans-serif; text-decoration: none;}
.plotwork-sign-in-button:focus-visible { outline: 2px solid #0f766e; outline-offset: 2px;}
@media (prefers-color-scheme: dark) { .plotwork-sign-in-button { border-color: #404040; background: #141414; color: #fafafa; }}In React, the same button as a component:
export function SignInWithPlotwork({ href = '/auth/plotwork' }: { href?: string }) { return ( <a className="plotwork-sign-in-button" href={href}> <svg viewBox="3 3 26 26" width="20" height="20" aria-hidden="true"> <defs> <linearGradient id="plotwork-mark" gradientUnits="userSpaceOnUse" x1="2" y1="3" x2="30" y2="29"> <stop offset="0" stopColor="#c6f04a" /> <stop offset="0.55" stopColor="#14b8a6" /> <stop offset="1" stopColor="#0f766e" /> </linearGradient> </defs> <path d="M3 3H14A11 11 0 0 1 3 14Z" fill="url(#plotwork-mark)" /> <path d="M29 3V14A11 11 0 0 1 18 3Z" fill="url(#plotwork-mark)" /> <path d="M29 29H18A11 11 0 0 1 29 18Z" fill="url(#plotwork-mark)" /> <path d="M3 29V18A11 11 0 0 1 14 29Z" fill="#0f766e" /> </svg> <span>Sign in with Plotwork</span> </a> )}Keep the words and the mark as they are, and do not suggest that Plotwork made or endorses your app. The trademarks and icons page has the rules for the logo.
Troubleshooting
| Error | Where | Means | Fix |
|---|---|---|---|
oauth.invalid_client | /oauth/authorize, 400 | No app has that client id | Copy the client id from the portal again. |
oauth.invalid_redirect_uri | /oauth/authorize, 400 | The address is not one the app registered | Register it exactly, with its port and path, or send one that is. |
invalid_request | Your redirect address, or /oauth/token | PKCE missing or not S256, a challenge of the wrong shape, a state too long, a missing field, a field sent twice, or a body that is not form-encoded | Send every field once, form-encoded, with an S256 challenge. |
invalid_scope | Your redirect address, or a refresh | A scope Plotwork does not know, or a refresh asking for more than the connection holds | Ask only for the five scopes; a refresh may only narrow. |
access_denied | Your redirect address | The person said no | Show the button again. |
invalid_client | /oauth/token, /oauth/revoke, 401 | The secret is wrong, sent twice, or missing for a confidential app | Send the current secret once, by HTTP Basic or client_secret. |
invalid_grant | /oauth/token, 400 | The code or refresh token expired, was used already, belongs to another app or address, the verifier is wrong, or the connection ended | Sign the person in again. A reuse also ended the connection. |
unsupported_grant_type | /oauth/token, 400 | A grant other than authorization_code or refresh_token | Use one of the two. |
oauth.invalid_token | /v1, 401 | The access token is unknown, expired or its connection ended | Refresh, or sign in again. |
oauth.insufficient_scope | /v1, 403 | The connection lacks the scope in details.scope | Ask the person to approve the app again with that scope. |
oauth.request_used, oauth.request_expired, oauth.request_invalid | The consent page | The sign-in link was answered already, is past its ten minutes, or was not made by Plotwork | Start again from the app. |
oauth.app_members_only | The consent page, 403 | The app is an organisation's, for its members only | Connect it as a member, to that organisation. |
oauth.app_not_allowed | The consent page, 403 | The organisation allows only approved apps, and this is not one | Ask an owner or admin to add the client id under Allowed apps, or connect another account. |
apps.invalid | Registering an app, 422 | A redirect address or homepage breaks the rules, or a public app was given a secret | Follow the rules in Register an app. |
apps.limit_reached | Registering an app, 409 | The account or organisation already has 25 apps | Delete one first. |
oauth.unavailable | Any endpoint, 503 | Sign in with Plotwork is not available on this server | Use https://api.plotwork.dev, or a server where it is turned on. |
See also
| Page | Why |
|---|---|
| SDKs | The TypeScript and Python clients and the viewer |
| Developer terms | What an app may do with what it receives |
| API reference | Every /v1 operation a token can call |
| Start Sign in with Plotwork | The authorize endpoint in detail |
| Trade a code or a refresh token | The token endpoint in detail |
| Hosted diagrams | Stable links and embed links, for pages with no sign-in |
| Organisations | Roles, the private library and the owner's settings |
| Privacy policy | What Plotwork keeps about a connection |