---
title: "OAuth für Apps"
description: "Apps anderer Anbieter verbinden sich über OAuth 2.1 mit Bodo: Authorization Code mit PKCE, kurze Access-Tokens, rotierende Refresh-Tokens und Resource Indicators."
lang: de
url: https://developers.bodo-app.com/guides/oauth/
apiVersion: 2026-11-01
---

# OAuth für Apps

Apps anderer Anbieter verbinden sich über OAuth 2.1 mit Bodo: Authorization Code mit PKCE, kurze Access-Tokens, rotierende Refresh-Tokens und Resource Indicators.

> **Wer entscheidet** Der Nutzer stimmt zu, kein Admin gibt Apps frei. Deine App bekommt nie mehr, als der zustimmende Nutzer selbst darf, und nur, wenn die API seiner Organisation an ist. Schaltet die Organisation die API aus, enden alle App-Tokens dort.

## Der Ablauf

Zehn Nachrichten zwischen vier Beteiligten. Die Adressen liest deine App aus `/.well-known/oauth-authorization-server`.

1. Deine App → Bodo: Anmeldung und Zustimmung: Weiterleitung zu /oauth/authorize mit PKCE (S256) und resource
2. Nutzer im Browser → Bodo: Anmeldung und Zustimmung: meldet sich an und wählt die Organisation
3. Bodo: Anmeldung und Zustimmung → Nutzer im Browser: zeigt das Zustimmungsfenster: was die App lesen und ändern will
4. Nutzer im Browser → Bodo: Anmeldung und Zustimmung: erlaubt
5. Bodo: Anmeldung und Zustimmung → Deine App: zurück zur redirect_uri mit code und state
6. Deine App → Bodo: Anmeldung und Zustimmung: POST /oauth/token mit code und code_verifier
7. Bodo: Anmeldung und Zustimmung → Deine App: Access-Token (10 min) und Refresh-Token
8. Deine App → Bodo API: GET /v1/… mit Authorization: Bearer
9. Bodo API → Deine App: Antwort mit den Rechten des Nutzers, geschnitten mit der Zustimmung
10. Deine App → Bodo: Anmeldung und Zustimmung: Refresh: neues Refresh-Token, das alte ist ungültig

später, ohne den Nutzer: Refresh-Token verfällt nach 30 Tagen ohne Nutzung, die Zustimmung nach 365 Tagen

## 1 · App registrieren

In Bodo unter [Einstellungen › Schnittstellen › OAuth-Apps](https://bodo-app.com/settings/oauth-apps): Name, Logo, Redirect-URI. Du bekommst eine `client_id`. Öffentliche Clients (Browser, Mobil) brauchen kein Secret, PKCE ist Pflicht.

| Feld | Beispiel |
| --- | --- |
| Name | Muster CRM-Sync |
| Redirect-URI | https://app.musterfirma.de/oauth/callback |
| client_id | app_7Hq2… |

## 2 · Zur Zustimmung schicken

Mit PKCE (S256) und `resource`, damit das Token nur für die API gilt.

```http
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.com
```

## Was der Nutzer sieht

**So sieht der Nutzer das Zustimmungsfenster**

> **Muster CRM-Sync möchte auf Bodo zugreifen**
> Angemeldet als Erika Mustermann · Musterfirma GmbH
>
> - Lesen: Kontakte
> - Ändern: Aufgaben anlegen, ändern, erledigen
> - Dauerhaft: Zugriff bleibt, bis du ihn widerrufst (offline_access)
>
> Die App bekommt nie mehr, als du selbst darfst. Widerrufen kannst du jederzeit unter Profil › Verbundene Apps.
> Darunter die Knöpfe Ablehnen und Erlauben.

## 3 · Code gegen Tokens tauschen

Mit dem `code_verifier` aus Schritt 2. Das Access-Token gilt 10 Minuten.

```http
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 · Erneuern mit Rotation

Jeder Refresh liefert ein neues Refresh-Token, das alte ist danach ungültig.

| Fall | Folge |
| --- | --- |
| **Richtig** | neues Refresh-Token sofort speichern, altes verwerfen |
| **Wiederverwendung** | ein altes Refresh-Token erneut benutzt: Bodo widerruft die ganze Kette, der Nutzer muss neu zustimmen |
| **Ablauf** | ein Refresh-Token, das 30 Tage unbenutzt bleibt, verfällt; die Zustimmung selbst nach 365 Tagen |

## Mit dem SDK

Die SDKs bauen PKCE, tauschen den Code und erneuern das Token vor Ablauf. Du speicherst nur das jeweils neue Refresh-Token.

TypeScript · SDK:

```typescript
// 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);
```

Python · SDK:

```python
# PKCE, consent, code exchange and refresh with rotation.
# Run: BODO_CLIENT_ID=bodo_ci_… BODO_CLIENT_SECRET=… python examples/oauth.py
import os
import secrets

from bodo_api import Bodo, OAuthSession, build_authorize_url, create_pkce_pair, exchange_code
from bodo_api.oauth import OFFLINE_ACCESS_SCOPE

client_id = os.environ["BODO_CLIENT_ID"]
client_secret = os.environ.get("BODO_CLIENT_SECRET")
redirect_uri = "https://crm.musterfirma.de/oauth/callback"

# Step 2 · send the user to the consent page
pkce = create_pkce_pair()
state = secrets.token_urlsafe(16)
print(
    build_authorize_url(
        client_id=client_id,
        redirect_uri=redirect_uri,
        scope=["contacts:read", "contacts:write", OFFLINE_ACCESS_SCOPE],
        state=state,
        code_challenge=pkce.challenge,
    )
)

# Step 3 · your callback receives ?code=…&state=… — compare state, then trade the code
tokens = exchange_code(
    client_id=client_id,
    client_secret=client_secret,
    code=os.environ["BODO_OAUTH_CODE"],
    code_verifier=pkce.verifier,
    redirect_uri=redirect_uri,
)

# Step 4 · one session per grant: it refreshes before expiry and rotates the refresh token
session = OAuthSession.from_tokens(
    client_id,
    tokens,
    client_secret=client_secret,
    # persist tokens.refresh_token here — the previous one is invalid from now on
    on_tokens=lambda rotated: print("rotated, valid until", rotated.expires_at),
)

with Bodo(oauth=session) as bodo:
    me = bodo.me()
    print(me.auth_method, me.principal_type)
```

## Scopes

Dieselben Rechte wie bei einem Schlüssel, geschnitten mit den Rechten des Nutzers.

| Scope | Erlaubt |
| --- | --- |
| contacts · companies · tasks · documents | `:read` oder `:write` |
| invoices · time_entries | `:read` oder `:write`, geldwirksam nur nach KI-Vollmacht des Nutzers |
| webhook_endpoints:write | Webhook-Endpunkte des Nutzers verwalten |
| offline_access | Refresh-Token für Zugriff ohne Anwesenheit |

Ein Token für eine andere `resource` weist die API mit [401 UNAUTHENTICATED](/problems/UNAUTHENTICATED/) ab. Löschen und Geld-Familien wirken nur, wenn der Nutzer sie einzeln ankreuzt.
