OAuth for apps
Apps of other vendors connect to Bodo through OAuth 2.1: authorization code with PKCE, short access tokens, rotating refresh tokens and resource indicators.
Who decidesThe user consents, no admin approves apps. Your app never gets more than the consenting user may do, and only while the API of their organization is on. When the organization switches the API off, all app tokens there end.
The flow
Ten messages between four parties. Your app reads the addresses from
/.well-known/oauth-authorization-server.User in the browser
Your app
Bodo: sign-in and consent
Bodo API
- redirect to
/oauth/authorizewith PKCE (S256) and resource1 - signs in and picks the organization2
- shows the consent window: what the app wants to read and change3
- allows4
- back to the
redirect_uriwith code and state5 POST /oauth/tokenwith code andcode_verifier6- access token (10 min) and refresh token7
GET /v1/…withAuthorization: Bearer8- answer with the user’s rights, intersected with the consent9
- later, without the user: the refresh token expires after 30 days unused, the consent after 365 days
- refresh: a new refresh token, the old one is invalid10
- 1Your app → Bodo: sign-in and consentredirect to
/oauth/authorizewith PKCE (S256) and resource - 2User in the browser → Bodo: sign-in and consentsigns in and picks the organization
- 3Bodo: sign-in and consent → User in the browsershows the consent window: what the app wants to read and change
- 4User in the browser → Bodo: sign-in and consentallows
- 5Bodo: sign-in and consent → Your appback to the
redirect_uriwith code and state - 6Your app → Bodo: sign-in and consent
POST /oauth/tokenwith code andcode_verifier - 7Bodo: sign-in and consent → Your appaccess token (10 min) and refresh token
- 8Your app → Bodo API
GET /v1/…withAuthorization: Bearer - 9Bodo API → Your appanswer with the user’s rights, intersected with the consent
- 10Your app → Bodo: sign-in and consentrefresh: a new refresh token, the old one is invalid
- later, without the user: the refresh token expires after 30 days unused, the consent after 365 days
1 · Register the app
In Bodo under Settings › Interfaces › OAuth apps ↗: name, logo, redirect URI. You get a
client_id. Public clients (browser, mobile) need no secret, PKCE is required.| Field | Example |
|---|---|
| Name | Muster CRM-Sync |
| Redirect URI | https://app.musterfirma.de/oauth/callback |
| client_id | app_7Hq2… |
2 · Send the user to consent
With PKCE (S256) and
resource, so the token is only valid for the API.GET https://api.bodo-app.com/oauth/authorize
?response_type=code
&client_id=app_7Hq2…
&redirect_uri=https://app.musterfirma.de/oauth/callback
&scope=contacts:read tasks:write offline_access
&state=9f2c…
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
&resource=https://api.bodo-app.comWhat the user sees
Muster CRM-Sync wants to access BodoSigned in as Erika Mustermann · Musterfirma GmbH
- ReadContacts
- ChangeCreate, change and complete tasks
- LastingAccess stays until you revoke it (offline_access)
The app never gets more than you may do yourself. You can revoke it at any time under Profile › Connected apps.
DenyAllow
Below it the buttons Deny and Allow.3 · Trade the code for tokens
With the
code_verifier from step 2. The access token is valid for 10 minutes.POST https://api.bodo-app.com/oauth/token
grant_type=authorization_code&code=…&code_verifier=…
&client_id=app_7Hq2…&resource=https://api.bodo-app.com
{
"access_token": "eyJhbGciOiJFUzI1NiIs…",
"token_type": "Bearer",
"expires_in": 600,
"refresh_token": "bodo_rt_8Vd1…",
"scope": "contacts:read tasks:write offline_access"
}4 · Refresh with rotation
Every refresh returns a new refresh token; the old one is invalid from then on.
| Case | Consequence |
|---|---|
| Correct | store the new refresh token at once, drop the old one |
| Reuse | an old refresh token used again: Bodo revokes the whole chain, the user has to consent again |
| Expiry | a refresh token unused for 30 days expires; the consent itself after 365 days |
Scopes
The same rights as for a key, intersected with the rights of the user.
| Scope | Allows |
|---|---|
| contacts · companies · tasks · documents | :read or :write |
| invoices · time_entries | :read or :write, money actions only under the user’s AI mandate |
| webhook_endpoints:write | manage the user’s webhook endpoints |
| offline_access | refresh token for access while the user is away |
The API rejects a token for another resource with 401 UNAUTHENTICATED. Deleting and the money families only work when the user ticks them one by one.
With the SDK
The SDKs build PKCE, trade the code and refresh the token before it expires. You only store the latest refresh token.
// Portal D12 · OAuth — PKCE, consent, code exchange and refresh with rotation.
// Run: BODO_CLIENT_ID=bodo_ci_… BODO_CLIENT_SECRET=… bun examples/oauth.ts
import { Bodo } from "@bodo/api";
import {
buildAuthorizeUrl,
createPkcePair,
exchangeCode,
OAuthSession,
OFFLINE_ACCESS_SCOPE,
} from "@bodo/api/oauth";
const clientId = process.env.BODO_CLIENT_ID ?? "";
const clientSecret = process.env.BODO_CLIENT_SECRET;
const redirectUri = "https://crm.musterfirma.de/oauth/callback";
// Step 2 · send the user to the consent page
const pkce = await createPkcePair();
const state = crypto.randomUUID();
console.log(
buildAuthorizeUrl({
clientId,
redirectUri,
scope: ["contacts:read", "contacts:write", OFFLINE_ACCESS_SCOPE],
state,
codeChallenge: pkce.challenge,
}),
);
// Step 3 · your callback receives ?code=…&state=… — compare state, then trade the code
const code = process.env.BODO_OAUTH_CODE ?? "";
const tokens = await exchangeCode({
clientId,
clientSecret,
code,
codeVerifier: pkce.verifier,
redirectUri,
});
// Step 4 · one session per grant: it refreshes before expiry and rotates the refresh token
const session = new OAuthSession({
clientId,
clientSecret,
tokens,
onTokens: (next) => {
// persist next.refreshToken — the previous one is invalid from now on
console.log("rotated, valid until", new Date(next.expiresAt).toISOString());
},
});
const bodo = new Bodo({ oauth: session });
const me = await bodo.me();
console.log(me.authMethod, me.principalType);