diff --git a/markdownpages/profit/en/authentication.md b/markdownpages/profit/en/authentication.md
index 921ea65..73f20bf 100644
--- a/markdownpages/profit/en/authentication.md
+++ b/markdownpages/profit/en/authentication.md
@@ -1,100 +1,342 @@
---
-author: CLN
-date: 2026-09-21
+author: TOKL
+date: 2026-09-30
tags: GetConnector, AppConnector, Integration, Configuration, Authentication, Authorization
title: Authentication
---
-## Introduction
+# Authentication for the Profit API
-The AFAS Profit REST API supports two authentication methods:
-1. Classic token (discontinued as of 31 August 2027)
-2. OAuth
- 1. Client credentials flow
- 2. Authorization code flow with PKCE
+To call the Profit API, an external application must authenticate itself. Profit supports two methods:
-Which method is used depends on the settings of the [App Connector](https://docs.afas.help/profit/en/concepts#app-connector) that is being used.
+| Method | Description | Status |
+|---|---|---|
+| **OAuth** | The application first requests a temporary *access token* and uses it for each call. Based on the OAuth 2.1 Internet-Draft. | **Recommended**, default for all new integrations |
+| **Classic token** | The application includes the same static key in every call. | **Being phased out**, stops working after 31 August 2027 |
-Use TLS 1.2 at minimum for all requests.
+> ⚠️ **Classic tokens are being phased out**
+>
+> - Existing classic tokens expire on **1 May 2027**.
+> - After **31 August 2027**, app connectors using a classic token will no longer work.
+>
+> For all new integrations, always use OAuth and migrate existing integrations as quickly as possible. See [Classic tokens](#classic-tokens-being-phased-out) for the complete timeline and migration guidance.
+This article explains OAuth in terms of:
-## Classic token
-**Please note! This functionality will be discontinued on 31 August 2027. Make sure to switch to OAuth before that date.**
+- which client types exist (**confidential** and **public**);
+- which flows can be used per client type;
+- how a confidential client authenticates itself (**client secret** or **private key JWT**);
+- how to protect a public client's access token using **DPoP**.
-This method uses static tokens which you include in the HTTP Authorization header of all your requests. A token is unique to a single environment and is linked to a user. The permissions of that user affect the rights of the token.
+> **Note:** part of the OAuth functionality is new in **Profit 9**. This is indicated throughout the text with the label **(Profit 9)**.
-The AFAS administrator creates the token, or if you have access to AFAS Profit you can create it yourself. Follow the steps in [Configure your own app connector at a glance (Classic token)](https://help.afas.nl/help/NL/SE/142488.htm).
+---
+
+## Contents
+
+1. [Classic tokens (being phased out)](#classic-tokens-being-phased-out)
+2. [OAuth: overview](#oauth-overview)
+3. [Terminology: confidential client and public client](#terminology-confidential-client-and-public-client)
+4. [Endpoints](#endpoints)
+5. [Client authentication for a confidential client](#client-authentication-for-a-confidential-client)
+ 1. [Method 1: client secret](#method-1-client-secret)
+ 2. [Method 2: private key JWT (Profit 9)](#method-2-private-key-jwt-profit-9)
+6. [Confidential client: Client credentials flow](#confidential-client-client-credentials-flow)
+7. [Confidential client: Authorization code flow with PKCE](#confidential-client-authorization-code-flow-with-pkce)
+8. [Public client: Authorization code flow with PKCE and DPoP (Profit 9)](#public-client-authorization-code-flow-with-pkce-and-dpop-profit-9)
+9. [Calling a connector with the access token](#calling-a-connector-with-the-access-token)
+10. [Which combination should I choose?](#which-combination-should-i-choose)
+11. [Frequently asked questions](#frequently-asked-questions)
+
+---
+
+## Classic tokens (being phased out)
+
+> **Note:** authentication with a classic token is being phased out. For all new integrations, always use OAuth and migrate existing integrations as quickly as possible.
+
+With a classic token, the integrating application uses the same fixed key for every request. That key often remains valid for years. If the key leaks, an attacker can gain long-term access to the environment. With OAuth, only a short-lived access token is sent with each request. The fixed values, such as the client secret or private key, are used only when requesting a token.
+
+### Timeline
+
+| Date | What changes? |
+|---|---|
+| September 2026 | Existing classic tokens receive an expiration date of 1 May 2027. |
+| 1 May 2027 | Existing classic tokens expire. Integrations that depend on them stop working. You can still create new classic tokens with a limited validity period. |
+| 31 August 2027 | Final day on which app connectors with a classic token work. |
+### Migrating to OAuth (Profit 9)
-### Format and conversion
+From Profit 9, you do not need to create a new app connector to migrate. In the existing app connector, change the authentication type from **Classic token** to **Hybrid** or **OAuth**, and then complete the OAuth setup. The existing configuration, such as linked connectors and IP restrictions, remains unchanged.
-A classic token as generated in AFAS Profit looks like this:
+- **Hybrid**: classic token and OAuth work alongside each other temporarily so the vendor can convert and test the integration without interruption.
+- **OAuth**: only OAuth still works; the classic token is no longer accepted.
-``` xml
+Discuss with your integration partner which OAuth method and client authentication you want to use. The options are listed below in [OAuth: overview](#oauth-overview). A decision aid can be found under [Which combination should I choose?](#which-combination-should-i-choose).
+
+### Using a classic token
+
+A classic token generated by Profit looks like this:
+
+```xml
1949C1A9CD9AE4797950D94F55A7A4D056770472D4963CB9A8D3800BEE0CCE6A2
```
-To use this token in requests you must convert it to Base64. After conversion the token looks for example like this:
+To use the token in a call, convert the full XML string to **Base64**:
+
+```text
+PHRva2VuPjx2ZXJzaW9uPjE8L3ZlcnNpb24+PGRhdGE+OTQ5QzFBOUNEOUFFNDc5Nzk1MEQ5NEY1NUE3QTREMDU2NzcwNDcyRDQ5NjNDQjlBOEQzODAwQkVFMENDRTZBMjwvZGF0YT48L3Rva2VuPg==
+```
-``` xml
-PHRva2VuPjx2ZXJzaW9uPjE8L3ZlcnNpb24+PGRhdGE+QURFMzcwQkU4REFGNDBEMEExN0ZGQjkxNEU0MjY3NUU5OTk4QzJENTQ2QTJGNEZBM0U0RjNBQkZBODY3Qjk2RjwvZGF0YT48L3Rva2VuPg==
+Use this value in the `Authorization` header with the `AfasToken` prefix:
+
+```bash
+curl -X GET "https://.rest.afas.online/ProfitRestServices/connectors/Profit_Address?skip=0&take=100" \
+ -H "Accept: application/json" \
+ -H "Authorization: AfasToken PHRva2VuPjx2ZXJzaW9uPjE8L3ZlcnNpb24+PGRhdGE+OTQ5QzFBOUNEOUFFNDc5Nzk1MEQ5NEY1NUE3QTREMDU2NzcwNDcyRDQ5NjNDQjlBOEQzODAwQkVFMENDRTZBMjwvZGF0YT48L3Rva2VuPg=="
```
+Handle a classic token with care: it grants access to sensitive data. After it is created, you can no longer view the token in Profit. If you lose it, delete it and create a new one.
-### Applying the token
+---
+
+## OAuth: overview
+
+| Client type | Flow | Client authentication | Token protection | Available from |
+|---|---|---|---|---|
+| Confidential | Client credentials | Client secret | Bearer, optional DPoP | Current version |
+| Confidential | Client credentials | Private key JWT | Bearer, optional DPoP | **Profit 9** |
+| Confidential | Authorization code + PKCE | Client secret | Bearer, optional DPoP | Current version |
+| Confidential | Authorization code + PKCE | Private key JWT | Bearer, optional DPoP | **Profit 9** |
+| Public | Authorization code + PKCE | None (client has no secret) | **DPoP** | **Profit 9** |
+
+From **Profit 9** onward, client secrets also have a **validity period** and must be renewed periodically. See [Client secret validity period](#client-secret-validity-period-profit-9).
+
+---
+
+## Terminology: confidential client and public client
+
+OAuth distinguishes between two client types. The difference comes down to one question: **can the application keep a secret safe?**
+
+### Confidential client
-Use the token in the HTTP request header with an `AfasToken` prefix. Use the `Authorization` header with the token value:
+A confidential client runs in an environment managed by you and not accessible to end users, for example a web server, a back-end service, or a scheduled task on a server. Such an application can safely store a secret (a client secret or private key).
-``` xml
-AfasToken PHRva2VuPjx2ZXJzaW9uPjE8L3ZlcnNpb24+PGRhdGE+QURFMzcwQkU4REFGNDBEMEExN0ZGQjkxNEU0MjY3NUU5OTk4QzJENTQ2QTJGNEZBM0U0RjNBQkZBODY3Qjk2RjwvZGF0YT48L3Rva2VuPg==
+Because the application has a secret, Profit can verify, for each token request, **which application** is requesting the token. This is called **client authentication**.
+
+Examples:
+- an integration between Profit and a payroll package or webshop running on a server;
+- a web application with its own back-end that retrieves or updates Profit data on behalf of a user.
+
+### Public client (Profit 9)
+
+A public client runs on a user device or in an end-user environment. The code and configuration of the application are visible there, so a secret embedded in it is no longer a secret.
+
+Examples:
+- a single-page application (SPA) that runs entirely in the browser;
+- a mobile app;
+- a desktop application or command-line tool installed on the user's computer.
+
+A public client **has no client secret** and therefore performs no client authentication. Instead, three measures protect it:
+
+- the user logs in to Profit themselves (Authorization code flow);
+- **PKCE** prevents an intercepted authorization code from being exchanged by someone else;
+- **DPoP** binds the access token to a key that only the application has, so a stolen token becomes unusable.
+
+> **Rule of thumb:** in doubt? If a user can inspect or download the application code, it is a public client. Never place a client secret or private key in a browser application, mobile app, or desktop app.
+
+---
+
+## Endpoints
+
+| Environment | Endpoint | URL |
+|---|---|---|
+| Production | Authorize | `https://.rest.afas.online/ProfitRestServices/oauth/authorize` |
+| Production | Token | `https://.rest.afas.online/ProfitRestServices/oauth/token` |
+| Test | Authorize | `https://.resttest.afas.online/ProfitRestServices/oauth/authorize` |
+| Test | Token | `https://.resttest.afas.online/ProfitRestServices/oauth/token` |
+| Accept | Authorize | `https://.restaccept.afas.online/ProfitRestServices/oauth/authorize` |
+| Accept | Token | `https://.restaccept.afas.online/ProfitRestServices/oauth/token` |
+
+Replace `` with the number of the Profit environment, for example `12345`.
+
+---
+
+## Client authentication for a confidential client
+
+A confidential client proves, for each request to the token endpoint, who it is. Profit supports **two methods** for this. You choose one method per app connector.
+
+| | Client secret | Private key JWT (Profit 9) |
+|---|---|---|
+| What do you share with Profit? | A shared secret (the secret) | An X.509 certificate or the URL of your JWK Set |
+| What do you send when requesting a token? | The secret itself | A short-lived, signed JWT |
+| Risk if intercepted | The secret can be reused until it expires or is revoked | The JWT is short-lived and valid for a single use |
+| Management | Renew secrets periodically (Profit 9) | Manage key pairs and rotate them in time |
+
+Both methods work with both the Client credentials flow and the Authorization code flow. The flow determines **how** you receive a token; the client authentication method determines **how you prove which application** you are.
+
+### Method 1: client secret
+
+When you configure the app connector in Profit, you receive a `client_id` and a `client_secret`. You send both in the body of the token request:
+
+```http
+client_id=
+client_secret=
```
-**Note**: Handle the token carefully as it provides access to sensitive data. Follow best practices when storing and managing the token and consider having your integration reviewed by an external security expert to address potential vulnerabilities.
+From **Profit 9**, you can also use `client_secret_basic`: send both values in the `Authorization: Basic ` header and omit `client_secret` from the body. Before Base64 encoding, the value is `FORM_URLENCODE(client_id):FORM_URLENCODE(client_secret)`: encode **each value separately** as `application/x-www-form-urlencoded`, as specified in [RFC 6749 §2.3.1](https://www.rfc-editor.org/rfc/rfc6749#section-2.3.1). Do not use the raw values if they contain, for example, `:` or other special characters. You may include `client_id` in the body if it matches the one in the Basic header; omitting it avoids confusion.
+
+Store the client secret like a password: in a secret store or key vault, never in source code, version control, or logs.
+
+#### Client secret validity period (Profit 9)
-### Generating a token for a user via OTP
+From Profit 9, every client secret has a **validity period**. After the expiry date, Profit rejects token requests that use that secret. You must therefore **renew the secret periodically**.
-AFAS provides the option to use a One Time Password (OTP) to obtain a token. This is useful in scenarios where users must register themselves in an application.
+- The validity period is configurable. The default value is 180 days.
+- You can find the expiry date in the app connector settings screen in Profit.
+**How to renew a secret without interruption:**
-### Unauthorized
+1. Generate a new secret for the app connector in Profit.
+2. Add the new secret to the configuration of the integrating application.
+3. Verify that the application can request tokens using the new secret.
+4. Revoke the old secret.
-If the token is invalid or not applied correctly you will receive an HTTP 401 response. Request a new token or validate that you convert the token correctly. Use the tooling on [connect.afas.nl](https://connect.afas.nl) to validate that you are making the request correctly.
+> **Tip:** plan the renewal well before the expiry date, for example as a recurring task in your maintenance calendar. An expired secret means the integration stops working.
+Do you not want to exchange secrets periodically? Use [private key JWT](#method-2-private-key-jwt-profit-9) instead.
+### Method 2: private key JWT (Profit 9)
-## OAuth
+With private key JWT, you use an **asymmetric key pair**:
-Within the OAuth protocol we support two flow types:
-1. Client credentials flow
-2. Authorization code flow with PKCE
+- the **private key** remains with you and never leaves your server;
+- you provide the public key through an X.509 certificate in Profit or through a JWK Set at your own URL.
+Instead of a secret, you send a short-lived JWT for each token request (a *client assertion*) that you sign with your private key. Profit verifies the signature with the public key. No reusable secret therefore goes over the line. This method is based on [RFC 7523](https://www.rfc-editor.org/rfc/rfc7523) and the `private_key_jwt` method from OpenID Connect.
-### Client credentials flow
+Profit supports `RS256`, `PS256`, and `ES256`. For RSA, use at least 2048 bits. For EC, use the P-256 curve. These requirements apply to both key sources.
-The Client Credentials Flow is primarily used for server-to-server communication where there is no direct involvement of an end user. This flow is ideal for applications that need access to resources on their own behalf rather than on behalf of a user. It is suitable for situations where an application requires access to APIs to perform background tasks, such as syncing data or running batch jobs.
+#### Step 1: create a key pair and provide the public key
+
+For example, use OpenSSL to create an RSA key pair and an X.509 certificate:
+
+```bash
+# RSA key pair (2048 bits or more)
+openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out private_key.pem
+openssl rsa -in private_key.pem -pubout -out public_key.pem
+openssl req -new -x509 -key private_key.pem -out certificate.pem -days 3650 -subj "/CN=my-app"
+```
-When an app connector uses the Client Credentials Flow, an 'OAuth client id' and an 'OAuth client secret' are created. Follow the steps in [Configure your own app connector at a glance (OAuth token)](https://help.afas.nl/help/NL/SE/120718.htm).
-The OAuth client secret is provided once during creation and cannot be retrieved afterwards.
+The private key (`private_key.pem`) remains with you and is never sent to Profit. You can provide the public key to Profit in two ways:
-#### Steps to access the API
+1. **File:** upload `certificate.pem` to the app connector. A standalone public key such as `public_key.pem` is not sufficient. You can optionally enter a key ID for the certificate. If you leave this field empty, Profit uses the certificate's SHA-1 thumbprint as the key ID.
+2. **JWKS URL (`jwks_uri`):** publish a JWK Set at an HTTPS URL and register this URL in the app connector. Profit then retrieves the public key automatically. The URL must not redirect and the response must not exceed 64 KB. Every key that is used has `"use": "sig"`. If the set contains more than one key, `kid` is required.
-To access the API, follow these steps:
-1. Obtain an access token
- 1. Call the [token endpoint](#token-endpoint) (POST) with the following information in the body:
- 1. grant_type: client_credentials
- 2. client_id: ``
- 3. client_secret: ``
- 2. In the response of this call you will find the following fields:
- 1. access_token: the access token you must add to the Authorization header.
- 2. refresh_token: for the client credentials flow this is always "null".
- 3. token_type: Bearer
- 4. expires_in: validity of the access token in seconds.
- 3. Use the access token
- 1. Copy the access token, prefix it with 'Bearer', and add it to your Authorization header.
+Profit caches a JWK Set for one hour by default and honors the `Cache-Control` header. When it encounters an unknown `kid`, Profit immediately retrieves the set again, so a new key becomes available almost immediately. When removing a key, keep in mind that a cached key may still be accepted for up to one hour.
-#### cURL examples
+#### Best practices for partners managing multiple customers
+
+If you manage integrations for multiple customers, use a **separate key pair for each customer environment**. At a minimum, use separate keys for production and test or acceptance environments. Do not reuse one private key for all customers: if that key leaks, all app connectors that trust the corresponding public key may be at risk.
+
+The **private key** is secret and must be stored securely for each customer environment. You can share the certificate or public key with Profit; the private key must remain in the partner's secure environment. Also limit internal access to the private keys.
+
+If you use a JWKS URL, publish a separate JWK Set for each customer environment containing only that customer's public key(s), and register the corresponding URL in that customer's app connector. Do not use the same JWK Set for multiple customers if it contains all of their keys. This helps prevent a key intended for one customer from also being trusted for other customers.
+
+#### Step 2: create the client assertion
+
+The JWT contains the following data:
+
+**Header**
+
+```json
+{
+ "alg": "RS256",
+ "typ": "JWT",
+ "kid": ""
+}
+```
+
+The `kid` header is optional when you store certificates as files. If you include `kid`, it must exactly match the key ID configured for the certificate in Profit, or the SHA-1 thumbprint if you left that field empty. An unknown `kid` is rejected even if the certificate is otherwise valid. Without `kid`, you can include `x5t#S256` or omit both identifying headers; Profit then tries all valid certificates for the app connector. Using a `kid` limits this search and can therefore improve performance.
+
+For a JWKS URL, `kid` comes from the JWK Set. If the set contains more than one key, the assertion must contain a `kid`. Its value must exactly match a key in the set.
+
+> **Practical note:** many JWT libraries populate `kid` automatically when the signing key has a key ID. If you do not want to use the thumbprint as `kid`, either configure the same key ID in Profit that your library sends, or clear the key ID in the library.
+
+**Payload**
+
+| Claim | Value |
+|---|---|
+| `iss` | Your `client_id` |
+| `sub` | Your `client_id` |
+| `aud` | With `typ: JWT`: the token endpoint URL or the base URL. With `typ: client-authentication+jwt`: the base URL, which is the token URL without `/oauth/token`. |
+| `jti` | A unique, random value per JWT (for example, a GUID). Profit rejects reuse of a `jti`. |
+| `iat` | Time of creation (Unix timestamp) |
+| `exp` | Expiration time (Unix timestamp). Prefer 60 seconds after `iat`. The maximum is 5 minutes, with 60 seconds of permitted clock skew. |
+
+```json
+{
+ "iss": "",
+ "sub": "",
+ "aud": "https://.rest.afas.online/ProfitRestServices/oauth/token",
+ "jti": "5f1d3b2a-8c4e-4b8e-9a61-2f0d7c3e9b14",
+ "iat": 1790000000,
+ "exp": 1790000300
+}
+```
+
+Sign the JWT with your private key.
+
+#### Step 3: send the client assertion
+
+In the token request, replace `client_secret` with:
+
+```http
+client_id=
+client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
+client_assertion=
+```
+
+Create a new JWT for **every** token request.
+
+Send exactly one client authentication method. From Profit 9, a request containing both `client_secret` and `client_assertion`, or Basic authentication and a credential in the body, returns `invalid_request`. The same applies if a `client_id` in the body differs from the one in the Basic header.
+
+From Profit 9, an unknown client or failed client authentication returns `invalid_client`. Failed Basic authentication returns HTTP `401` with `WWW-Authenticate: Basic`; other methods return HTTP `400`. An incorrect client secret is also rejected.
+
+#### Rotating keys
+
+You also rotate key pairs periodically. Procedure:
+
+1. Create a new key pair for the customer environment and register the new certificate or add the new key to that customer's JWK Set. Multiple certificates can be active at the same time.
+2. Configure your application to sign JWTs with the new private key (with the matching `kid`).
+3. Verify that the new key works. Keep the old certificate or JWK available for a reasonable, agreed overlap period so the transition can complete smoothly. If you use a JWKS URL, leave the old key in the JWK Set during this period. Take the cache duration into account: Profit caches the set for one hour by default and honors the `Cache-Control` header.
+4. Remove the old certificate or JWK after the overlap period.
+
+During the transition and overlap period, two keys are temporarily active for that customer environment. This follows the [key rotation guidance in OpenID Connect Core](https://openid.net/specs/openid-connect-core-1_0.html#RotateSigKeys). If you suspect that a private key has leaked, revoke the corresponding public key in Profit or remove it from the JWK Set immediately, then replace the key; do not wait for the overlap period to end. Also check that the relevant app connectors and customer environments use separate keys.
+
+### Token request errors (Profit 9)
+
+A missing `grant_type` returns `invalid_request`. When exchanging an authorization code, a missing `code`, `redirect_uri`, or `code_verifier` also returns `invalid_request`. Do not repeat parameters in a token request: multiple values for the same parameter return `invalid_request` instead of being combined. Profit omits `error_description` if there is no description; do not expect a field with a `null` value.
+
+---
+
+## Confidential client: Client credentials flow
+
+Use this flow for **automatic integrations between two systems**. No user logs in: the application requests a token itself and runs under the user configured on the app connector. For most integrations, this is the correct choice.
+
+```
+Application Profit
+ | |
+ |-- POST /oauth/token --------------------> |
+ | grant_type=client_credentials |
+ | + client authentication |
+ | |
+ |<-- access_token -------------------------- |
+ | |
+ |-- GET /connectors/... (Bearer token) ---> |
+```
+
+### Requesting a token with a client secret
-**Retrieve token:**
```bash
curl -X POST https://.rest.afas.online/ProfitRestServices/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
@@ -103,123 +345,376 @@ curl -X POST https://.rest.afas.online/ProfitRestServices/oau
-d "client_secret="
```
-**Response example:**
+### Requesting a token with private key JWT (Profit 9)
+
+```bash
+curl -X POST https://.rest.afas.online/ProfitRestServices/oauth/token \
+ -H "Content-Type: application/x-www-form-urlencoded" \
+ -d "grant_type=client_credentials" \
+ -d "client_id=" \
+ -d "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer" \
+ -d "client_assertion="
+```
+
+### Response (Profit 9)
+
```json
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
- "refresh_token": null,
"token_type": "Bearer",
"expires_in": 3600
}
```
-**API call with token:**
+The access token is valid for `expires_in` seconds. In this flow, you receive no refresh token; from Profit 9, the `refresh_token` field is therefore omitted from the response. After it expires, simply request a new token in the same way. Reuse the token while it is valid instead of requesting a new one for every call.
+
+---
+
+## Confidential client: Authorization code flow with PKCE
+
+Use this flow when an application with its own back-end retrieves or updates Profit data **on behalf of a user**. The user logs in to Profit and Profit checks whether the user is a member of the authorization group linked to the app connector.
+
+Profit requires **PKCE** (Proof Key for Code Exchange, [RFC 7636](https://www.rfc-editor.org/rfc/rfc7636)) in this flow. PKCE ensures that only the application that started the login can exchange the authorization code for a token.
+
+```
+User Application (back-end) Profit
+ | | |
+ | 1. Login --> | |
+ | | 2. code_verifier + challenge |
+ |<-- 3. redirect to /oauth/authorize ------------->|
+ |------------------ 4. user logs in ----------->|
+ |<-- 5. redirect to redirect_uri with code ---------|
+ |----------------> | |
+ | | 6. POST /oauth/token |
+ | | code + code_verifier |
+ | | + client authentication ---->|
+ | |<-- 7. access_token -------------|
+```
+
+### Step 1: create a code verifier and code challenge
+
+- **`code_verifier`**: a random string of 43 to 128 characters (letters, digits, and `-._~`). Store it temporarily on the server, linked to the user's session.
+- **`code_challenge`**: the SHA-256 hash of the `code_verifier`, Base64URL-encoded without the `=` padding.
+
+```text
+code_challenge = BASE64URL( SHA256( code_verifier ) )
+```
+
+Also create a random **`state`**. You use this in step 3 to verify that the response belongs to the request you started yourself. This protects against CSRF.
+
+### Step 2: redirect the user to Profit
+
+Send the user's browser to the authorize endpoint:
+
+```text
+https://.rest.afas.online/ProfitRestServices/oauth/authorize
+ ?response_type=code
+ &client_id=
+ &redirect_uri=
+ &state=
+ &code_challenge=
+ &code_challenge_method=S256
+```
+
+| Parameter | Description |
+|---|---|
+| `response_type` | Always `code` |
+| `client_id` | The client ID of the app connector |
+| `redirect_uri` | The URL to which Profit sends the user back. It must match the redirect URL configured in the app connector. From Profit 9, Profit compares the scheme and host case-insensitively; the path, query, and port must match exactly. |
+| `state` | Random value that you verify on return |
+| `code_challenge` | The code challenge from step 1 |
+| `code_challenge_method` | Always `S256` |
+
+### Step 3: receive the authorization code
+
+After logging in, Profit redirects the user back to your `redirect_uri`:
+
+```text
+?code=&state=
+```
+
+Check that `state` matches the value from step 1. If it does not, abort the process. The authorization code is short-lived and can be used only once.
+
+From **Profit 9**, errors that occur after the `redirect_uri` has been validated are sent as redirects to that URL, including a non-empty supplied `state`. For example, `response_type=token` returns `unsupported_response_type` through the redirect. An unexpected error, such as one during the exchange with the STS, is also returned as an `error=server_error` redirect rather than a bare HTTP `500` from the authorize endpoint. Check `state` on error redirects too before processing the error. If the `redirect_uri` has not been validated, Profit does not redirect to it.
+
+An empty `state` is omitted from the response. An empty `nonce` is also not included as a value in the response. Always send a non-empty random `state` so you can match the response to the original request.
+
+### Step 4: exchange the code for a token
+
+Exchange the code via your **back-end**, using the `code_verifier` from step 1 and your client authentication.
+
+**With client secret:**
+
```bash
-curl -X GET "https://.rest.afas.online/ProfitRestServices/connectors/Profit_Address?skip=0&take=100" \
- -H "Accept: application/json" \
- -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
+curl -X POST https://.rest.afas.online/ProfitRestServices/oauth/token \
+ -H "Content-Type: application/x-www-form-urlencoded" \
+ -d "grant_type=authorization_code" \
+ -d "code=" \
+ -d "redirect_uri=" \
+ -d "code_verifier=" \
+ -d "client_id=" \
+ -d "client_secret="
+```
+
+**With private key JWT (Profit 9):**
+
+```bash
+curl -X POST https://.rest.afas.online/ProfitRestServices/oauth/token \
+ -H "Content-Type: application/x-www-form-urlencoded" \
+ -d "grant_type=authorization_code" \
+ -d "code=" \
+ -d "redirect_uri=" \
+ -d "code_verifier=" \
+ -d "client_id=" \
+ -d "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer" \
+ -d "client_assertion="
```
-**Response example:**
+**Response (Profit 9):**
+
```json
{
- "skip": 0,
- "take": 100,
- "rows": [
- {
- "AddressId": 1,
- "AddressLine": "Stadsring 69, 3811 HN AMERSFOORT",
- "PoBox": false,
- "Address": "Stadsring",
- "Number": 69,
- "ZipCode": "3811 HN",
- "Recidence": "Amersfoort",
- "Country": "NL"
- }
- ]
+ "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
+ "refresh_token": "",
+ "token_type": "Bearer",
+ "expires_in": 3600
}
```
-### Authorization code flow with PKCE
-
-The Authorization Code Flow with PKCE is ideal for web applications that need to obtain access to resources on behalf of a user. The process starts with user authentication and authorization, where the user logs in and grants permission. An authorization code is then issued, which can be exchanged for an access token. This flow provides a secure way to access data from external services because it requires the user's involvement before access is granted.
-
-> The redirect_uri must match one of the redirect URLs registered in the App Connector. To test on AFAS Connect, you must register https://connect.afas.nl/oauth/callback. After creating an App Connector, you can register multiple redirect URLs.
-
-
-#### Steps to access the API
-
-To access the API via the Authorization Code Flow, follow these steps:
-1. Obtain an authorization code
- 1. Redirect the user to the [authorization endpoint](#authorization-endpoint) (GET) with the following parameters:
- 1. response_type: code
- 2. client_id: ``
- 3. redirect_uri: ``
- 4. state: ``
- 5. code_challenge: ``
- 6. code_challenge_method: ``
- 2. The user logs in and grants permission. After granting permission the user is redirected back to the provided redirect_uri with an authorization code.
-2. Exchange the authorization code for an access token
- 1. Call the [token endpoint](#token-endpoint) (POST) with the following information in the body:
- 1. grant_type: authorization_code
- 2. code: ``
- 3. redirect_uri: ``
- 4. client_id: ``
- 5. client_secret: ``
- 6. code_verifier: ``
-3. In the response of this call you will find the following fields:
- 1. access_token: the access token you must add to the Authorization header.
- 2. refresh_token: a token that can be used to obtain a new access token.
- 3. token_type: Bearer
- 4. expires_in: validity of the access token in seconds.
-4. Use the access token
- 1. Copy the access token, prefix it with 'Bearer', and add it to your Authorization header.
-5. Obtain a new access token using the refresh token
- 1. Call the [token endpoint](#token-endpoint) (POST) with the following information in the body:
- 1. grant_type: refresh_token
- 2. refresh_token: ``
- 3. client_id: ``
- 4. client_secret: ``
- 2. In the response of this call you will find the same fields as in step 3.
-
-#### cURL examples
-
-**Step 1: Redirect user to authorization endpoint:**
+The refresh token can be used only **once**. Once you exchange it for a new access token, it becomes invalid and you receive a new refresh token in the response. Save that new refresh token immediately and use it for the next renewal. If you accidentally use an old refresh token, Profit rejects the request.
+
+Refresh tokens are valid for a maximum of **30 days total**, counted from the moment the **first** refresh token was issued. A new refresh token does not extend this period. After 30 days, the user must log in again via the Authorization code flow. Keep this in mind in your application: catch the error for an expired refresh token and send the user back to the authorize endpoint.
+
+### Step 5: renew the token with a refresh token
+
```bash
-# Open this URL in a browser:
-https://.rest.afas.online/ProfitRestServices/oauth/authorize?response_type=code&client_id=&redirect_uri=&state=&code_challenge=&code_challenge_method=S256
+curl -X POST https://.rest.afas.online/ProfitRestServices/oauth/token \
+ -H "Content-Type: application/x-www-form-urlencoded" \
+ -d "grant_type=refresh_token" \
+ -d "refresh_token=" \
+ -d "client_id=" \
+ -d "client_secret="
+```
+
+If you use private key JWT, send `client_assertion_type` and `client_assertion` instead of `client_secret`.
+
+---
+
+## Public client: Authorization code flow with PKCE and DPoP (Profit 9)
+
+From Profit 9, you can configure an app connector as a **public client**. This is intended for browser applications (SPAs), mobile apps, and desktop applications that work with Profit on behalf of a user without their own back-end.
+
+A public client:
+
+- uses the **Authorization code flow with PKCE** (required);
+- sends **no** `client_secret` or `client_assertion`;
+- protects the access token with **DPoP** (required).
+
+### What is DPoP?
+
+A regular (*Bearer*) access token works like a pass: anyone who has it can use it. For a server, that is acceptable, but in a browser or on a user device the risk of token leakage is higher, for example via a vulnerability in the web page or malware.
+
+**DPoP** (Demonstrating Proof of Possession, [RFC 9449](https://www.rfc-editor.org/rfc/rfc9449)) binds the access token to a key pair created by the application itself:
+
+1. The application creates a key pair when it starts. The private key remains in the application (in the browser preferably as a *non-extractable* key via the Web Crypto API).
+2. For each request, the application sends a **DPoP proof**: a small JWT signed with the private key that belongs to that one request.
+3. Profit binds the issued access token to the application’s public key.
+4. For each API call, Profit verifies whether the DPoP proof was signed with the same key.
+
+The result: a stolen access token is unusable without the corresponding private key.
+
+### Step 1: create a key pair
+
+Create one key pair and retain it for at least the lifetime of the associated refresh-token chain; it must survive the OAuth redirect and any reload or restart in which the refresh token remains available. In a browser, persist the non-extractable `CryptoKey` in IndexedDB (or use an equivalent protected platform key store). Example:
+
+```javascript
+const keyPair = await crypto.subtle.generateKey(
+ { name: "ECDSA", namedCurve: "P-256" },
+ false, // private key not exportable
+ ["sign", "verify"]
+);
```
-**Step 2: Retrieve token with authorization code:**
+### Step 2: create the code verifier, code challenge, and state
+
+This works the same way as in the [confidential client](#step-1-create-a-code-verifier-and-code-challenge). Store the `code_verifier` and `state` in the application memory (for example `sessionStorage` in an SPA).
+
+### Step 3: redirect the user to Profit
+
+```text
+https://.rest.afas.online/ProfitRestServices/oauth/authorize
+ ?response_type=code
+ &client_id=
+ &redirect_uri=
+ &state=
+ &code_challenge=
+ &code_challenge_method=S256
+```
+
+### Step 4: create a DPoP proof for the token request
+
+A DPoP proof is a JWT with the following structure:
+
+**Header**
+
+```json
+{
+ "typ": "dpop+jwt",
+ "alg": "ES256",
+ "jwk": {
+ "kty": "EC",
+ "crv": "P-256",
+ "x": "",
+ "y": ""
+ }
+}
+```
+
+The header contains the **public** key of the application (`jwk`).
+
+**Payload**
+
+| Claim | Value |
+|---|---|
+| `jti` | Unique random value per proof |
+| `htm` | HTTP method of the request, for example `POST` |
+| `htu` | URL of the request, without the query string |
+| `iat` | Time of creation (Unix timestamp) |
+| `nonce` | The most recently received value from the `DPoP-Nonce` header (see [DPoP nonce](#dpop-nonce)) |
+
+```json
+{
+ "jti": "e1f3c9a2-4b7d-4c1e-8f2a-9d6b3e5a7c10",
+ "htm": "POST",
+ "htu": "https://.rest.afas.online/ProfitRestServices/oauth/token",
+ "iat": 1790000000
+}
+```
+
+Sign the proof with the private key from step 1. Create a new proof for **every** request.
+
+### Step 5: exchange the code for a token
+
+Include a previously received valid nonce in your DPoP proof. If you do not have one yet, complete the nonce exchange described under [DPoP nonce](#dpop-nonce) and repeat the request with a new proof.
+
```bash
curl -X POST https://.rest.afas.online/ProfitRestServices/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
+ -H "DPoP: " \
-d "grant_type=authorization_code" \
-d "code=" \
-d "redirect_uri=" \
- -d "client_id=" \
- -d "client_secret=" \
- -d "code_verifier="
+ -d "code_verifier=" \
+ -d "client_id="
```
-**Response example:**
+Note: there is **no** `client_secret` and **no** `client_assertion`.
+
+**Response (Profit 9):**
+
```json
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
- "token_type": "Bearer",
- "expires_in": 3600,
- "refresh_token": "50c90d85-a7aa-4e8a-a9b8-..."
+ "refresh_token": "",
+ "token_type": "DPoP",
+ "expires_in": 3600
+}
+```
+
+The `token_type` is `DPoP` instead of `Bearer`. This token works only together with a valid DPoP proof.
+
+### Step 6: call a connector with a DPoP token
+
+For every API call, you send two headers:
+
+- `Authorization: DPoP ` (note: `DPoP` instead of `Bearer`);
+- `DPoP: ` with a new proof for that call.
+
+The proof for an API call contains, in addition to `jti`, `htm`, `htu`, and `iat`, the claim **`ath`**: the SHA-256 hash of the access token, Base64URL-encoded.
+
+```json
+{
+ "jti": "7a2c4e6f-1b3d-4f5a-9c8e-2d4f6a8b0c1e",
+ "htm": "GET",
+ "htu": "https://.rest.afas.online/ProfitRestServices/connectors/Profit_Address",
+ "iat": 1790000060,
+ "ath": "",
+ "nonce": ""
+```
+
+```bash
+curl -X GET "https://.rest.afas.online/ProfitRestServices/connectors/Profit_Address?skip=0&take=100" \
+ -H "Accept: application/json" \
+ -H "Authorization: DPoP eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." \
+ -H "DPoP: "
+```
+
+### Step 7: renew the token
+
+You also send a DPoP proof when renewing the token, signed with **the same** key as the original token request.
+
+```bash
+curl -X POST https://.rest.afas.online/ProfitRestServices/oauth/token \
+ -H "Content-Type: application/x-www-form-urlencoded" \
+ -H "DPoP: " \
+ -d "grant_type=refresh_token" \
+ -d "refresh_token=" \
+ -d "client_id="
+```
+
+### DPoP nonce
+
+Profit requires a server-defined value (*nonce*) in the DPoP proof. If you have not yet received a valid nonce, send a request without one. Profit responds with `use_dpop_nonce` and the `DPoP-Nonce` header. If you have already stored a valid nonce, you can use it immediately.
+
+Profit checks the nonce before validating any client assertion. As a result, an assertion is not marked as used during the nonce challenge, and the repeated request is not rejected as a replay. The nonce requirement applies server-wide, so the challenge reveals nothing about the client.
+
+```http
+HTTP/1.1 400 Bad Request
+DPoP-Nonce:
+Content-Type: application/json
+
+{
+ "error": "use_dpop_nonce"
+}
+```
+
+Then create an entirely new DPoP proof with a new `jti`, include the received nonce, and repeat the original request:
+
+```json
+{
+ "jti": "7c4b96d1-76d8-42ea-9508-f2c5944e50d8",
+ "htm": "POST",
+ "htu": "https://.rest.afas.online/ProfitRestServices/oauth/token",
+ "iat": 1790000001,
+ "nonce": ""
}
```
-**Step 3: API call with token:**
+```bash
+curl -X POST https://.rest.afas.online/ProfitRestServices/oauth/token \
+ -H "Content-Type: application/x-www-form-urlencoded" \
+ -H "DPoP: " \
+ -d "grant_type=authorization_code" \
+ -d "code=" \
+ -d "redirect_uri=" \
+ -d "code_verifier=" \
+ -d "client_id="
+```
+
+Profit may return a new `DPoP-Nonce` with any response, including a successful response with status `200`. After every response, store the most recently received nonce and use it in the next DPoP proof.
+
+---
+
+## Calling a connector with the access token
+
+For confidential clients (Bearer token):
+
```bash
curl -X GET "https://.rest.afas.online/ProfitRestServices/connectors/Profit_Address?skip=0&take=100" \
-H "Accept: application/json" \
-H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
```
-**Response example:**
```json
{
"skip": 0,
@@ -227,7 +722,7 @@ curl -X GET "https://.rest.afas.online/ProfitRestServices/con
"rows": [
{
"AddressId": 1,
- "AddressLine": "Stadsring 69, 3811 HN AMERSFOORT",
+ "AddressLine": "Stadsring 69, 3811 HN AMERSFOORT",
"PoBox": false,
"Address": "Stadsring",
"Number": 69,
@@ -239,28 +734,57 @@ curl -X GET "https://.rest.afas.online/ProfitRestServices/con
}
```
-### OAuth & SOAP API
-The description above for both flows **also applies when using the SOAP API**. It is important to include the Bearer token in the header and not in the body.
+For public clients (DPoP token): see [Step 6](#step-6-call-a-connector-with-a-dpop-token).
+
+From **Profit 9**, every HTTP `401` from the resource server includes a `WWW-Authenticate` header. Its contents depend on the request:
+
+- **No credentials sent:** Profit offers both `Bearer` and `DPoP`, without an error code. You need to send an access token.
+- **Bearer token rejected:** the header includes `Bearer error="invalid_token"`. Request a new token or check the token you sent.
+- **DPoP token rejected:** the header includes `DPoP error="invalid_token"`. This also applies if the proof is signed with a different key than the one bound to the token ([RFC 9449 §7.1](https://www.rfc-editor.org/rfc/rfc9449#section-7.1)). Check the token and proof.
-### Token endpoint
-These endpoints apply to both REST and SOAP.
+If the replay-detection store is temporarily unavailable, Profit returns HTTP `503` instead of `401`. This is a server failure, not an indication that your token is invalid.
+
+You can call only the Get- and UpdateConnector endpoints that are linked to the app connector.
+
+The OAuth flows above also apply to the SOAP API. Follow the selected token type's HTTP-header requirements; do not put the access token in the SOAP body.
+
+---
+
+## Which combination should I choose?
+
+1. **Does a user log in?**
+ - **No** → confidential client with the **Client credentials flow**.
+ - **Yes** → continue to question 2.
+2. **Does your application have its own server (back-end) where you can store a secret safely?**
+ - **Yes** → confidential client with the **Authorization code flow with PKCE**.
+ - **No** (SPA, mobile app, desktop application) → **public client** with the **Authorization code flow with PKCE and DPoP** (Profit 9).
+3. **Confidential client: which client authentication?**
+ - **Private key JWT** (Profit 9) is preferred: no reusable secret goes over the line and you do not need to exchange secrets with AFAS or your customer.
+ - **Client secret** is easier to implement, but from Profit 9 you must renew the secret periodically.
+
+---
-**Production**: https://``.rest.afas.online/ProfitRestServices/oauth/token
+## Frequently asked questions
-**Accept**: https://``.restaccept.afas.online/ProfitRestServices/oauth/token
+**Do I need to modify my existing integration for Profit 9?**
+If you use a client secret, it gets a validity period from Profit 9. Make sure you have a process to renew the secret in time, or move to private key JWT. Otherwise, the Client credentials flow and Authorization code flow with PKCE continue to work as before.
-**Test**: https://``.resttest.afas.online/ProfitRestServices/oauth/token
+**I still use a classic token. What should I do?**
+Migrate to OAuth before 31 August 2027. Existing classic tokens already expire on 1 May 2027. From Profit 9, you can change the authentication type of your existing app connector. See [Migrating to OAuth](#migrating-to-oauth-profit-9).
-### Authorization endpoint
-These endpoints apply to both REST and SOAP.
+**Can I use a client secret in my mobile app or browser application?**
+No. A secret in an application running on the user side can be read. Configure the app connector as a public client from Profit 9 and use PKCE and DPoP.
-**Production**: https://``.rest.afas.online/ProfitRestServices/oauth/authorize
+**What is the difference between PKCE and DPoP?**
+PKCE protects the **authorization code**: only the application that started the login can exchange the code. DPoP protects the **access token**: only the application that owns the private key can use the token. A public client needs both.
-**Accept**: https://``.restaccept.afas.online/ProfitRestServices/oauth/authorize
+**What is the difference between the flow and client authentication?**
+The flow (Client credentials or Authorization code) determines **how** you obtain a token, for example whether a user logs in. Client authentication (client secret or private key JWT) determines **how you prove which application** you are. A confidential client always combines one flow with one client authentication method.
-**Test**: https://``.resttest.afas.online/ProfitRestServices/oauth/authorize
+**My token request returns `invalid_client`. What now?**
+Check whether the `client_id` is correct and whether the client secret has expired or been revoked (Profit 9). For private key JWT, check whether the certificate or `jwks_uri` is configured correctly, whether `kid` identifies the intended key, and whether the claims `iss`, `sub`, `aud`, and `exp` are correct.
### Read more
- [Profit API GetConnectors](./get-connector)
-- [Error handling](./troubleshooting)
\ No newline at end of file
+- [Error handling](./troubleshooting)
diff --git a/markdownpages/profit/nl/authentication.md b/markdownpages/profit/nl/authentication.md
index 74fad03..ad75ecb 100644
--- a/markdownpages/profit/nl/authentication.md
+++ b/markdownpages/profit/nl/authentication.md
@@ -1,99 +1,342 @@
---
-author: CLN
-date: 2026-09-21
+author: TOKL
+date: 2026-09-30
tags: GetConnector, AppConnector, Integration, Configuration, Authentication, Authorization
title: Authenticatie
---
-## Introductie
+# Authenticatie op de Profit API
-De AFAS Profit REST API ondersteunt twee manieren van authenticatie:
-1. Classic token (vervalt per 31 augustus 2027)
-2. OAuth
- 1. Client credentials flow
- 2. Authorization code flow with PKCE
+Om de Profit API aan te roepen, moet een externe applicatie zich authenticeren. Profit ondersteunt hiervoor twee methoden:
-Welke methode er gebruikt wordt, hangt af van de instellingen van de [App Connector](https://docs.afas.help/profit/nl/concepts#app-connector) waarvan gebruikgemaakt wordt.
+| Methode | Omschrijving | Status |
+|---|---|---|
+| **OAuth** | De applicatie vraagt eerst een tijdelijk *access token* aan en gebruikt dat bij elke aanroep. Gebaseerd op de OAuth 2.1 Internet-Draft. | **Aanbevolen**, standaard voor alle nieuwe koppelingen |
+| **Classic token** | De applicatie stuurt bij elke aanroep dezelfde vaste sleutel mee. | **Wordt uitgefaseerd**, werkt niet meer na 31 augustus 2027 |
-Gebruik minimaal TLS 1.2 voor alle requests.
+> ⚠️ **Classic tokens worden uitgefaseerd**
+>
+> - Bestaande classic tokens verlopen op **1 mei 2027**.
+> - Na **31 augustus 2027** werken app connectoren met een classic token niet meer.
+>
+> Gebruik voor nieuwe koppelingen altijd OAuth en stap met bestaande koppelingen zo snel mogelijk over. Zie [Classic tokens](#classic-tokens-wordt-uitgefaseerd) voor de volledige tijdlijn en de overstap.
-## Classic token
-**Let op! Deze functionaliteit komt per 31 augustus 2027 te vervallen. Zorg ervoor dat je vóór die datum overstapt op OAuth.**
+Voor OAuth beschrijft dit artikel:
-Deze methode maakt gebruik van statische tokens die je meegeeft in de HTTP-authenticatieheader van al je requests. Een token is uniek voor één omgeving en gekoppeld aan een gebruiker. De rechten van deze gebruiker hebben invloed op de rechten van het token.
+- welke soorten clients er zijn (**confidential** en **public**);
+- welke flows je per soort client kunt gebruiken;
+- hoe een confidential client zich authenticeert (**client secret** of **private key JWT**);
+- hoe je het access token van een public client beschermt met **DPoP**.
-Het aanmaken van het token doet de AFAS-beheerder, of als je toegang hebt tot AFAS Profit, kun je dit zelf doen. Hiervoor volg je de stappen in [Eigen app connector inrichten in vogelvlucht (Classic token)](https://help.afas.nl/help/NL/SE/142488.htm).
+> **Let op:** een deel van de OAuth-functionaliteit is nieuw in **Profit 9**. Dit is in de tekst steeds aangegeven met het label **(Profit 9)**.
+---
+
+## Inhoud
+
+1. [Classic tokens (wordt uitgefaseerd)](#classic-tokens-wordt-uitgefaseerd)
+2. [OAuth: overzicht](#oauth-overzicht)
+3. [Begrippen: confidential client en public client](#begrippen-confidential-client-en-public-client)
+4. [Endpoints](#endpoints)
+5. [Client authenticatie bij een confidential client](#client-authenticatie-bij-een-confidential-client)
+ 1. [Methode 1: client secret](#methode-1-client-secret)
+ 2. [Methode 2: private key JWT (Profit 9)](#methode-2-private-key-jwt-profit-9)
+6. [Confidential client: Client credentials flow](#confidential-client-client-credentials-flow)
+7. [Confidential client: Authorization code flow met PKCE](#confidential-client-authorization-code-flow-met-pkce)
+8. [Public client: Authorization code flow met PKCE en DPoP (Profit 9)](#public-client-authorization-code-flow-met-pkce-en-dpop-profit-9)
+9. [Een connector aanroepen met het access token](#een-connector-aanroepen-met-het-access-token)
+10. [Welke combinatie kies ik?](#welke-combinatie-kies-ik)
+11. [Veelgestelde vragen](#veelgestelde-vragen)
+
+---
+
+## Classic tokens (wordt uitgefaseerd)
+
+> **Let op:** authenticatie met een classic token wordt uitgefaseerd. Gebruik voor nieuwe koppelingen altijd OAuth en stap met bestaande koppelingen zo snel mogelijk over.
+
+Bij een classic token gebruikt de koppelende applicatie bij elke aanroep dezelfde vaste sleutel. Die sleutel blijft vaak jarenlang geldig. Als de sleutel uitlekt, heeft een kwaadwillende daardoor langdurig toegang tot de omgeving. Bij OAuth gaat alleen een kortlevend access token mee in de aanroep. De vaste gegevens, zoals een client secret of private key, worden alleen gebruikt bij het aanvragen van een token.
+
+### Tijdlijn
+
+| Datum | Wat verandert er? |
+|---|---|
+| September 2026 | Bestaande classic tokens krijgen een einddatum van 1 mei 2027. |
+| 1 mei 2027 | Bestaande classic tokens verlopen. Koppelingen die hiervan afhankelijk zijn, werken niet meer. Je kunt nog wel nieuwe classic tokens met een beperkte geldigheidsduur aanmaken. |
+| 31 augustus 2027 | Laatste dag waarop app connectoren met een classic token werken. |
-### Formaat en conversie
+### Overstappen naar OAuth (Profit 9)
-Een classic token zoals in AFAS Profit gegenereerd, ziet er als volgt uit:
+Vanaf Profit 9 hoef je voor de overstap geen nieuwe app connector aan te maken. In de bestaande app connector wijzig je het authenticatietype van **Classic token** naar **Hybride** of **OAuth** en doorloop je daarna de OAuth-inrichting. De bestaande inrichting, zoals de gekoppelde connectoren en IP-restricties, blijft behouden.
-``` xml
+- **Hybride**: classic token en OAuth werken tijdelijk naast elkaar, zodat de leverancier de koppeling kan omzetten en testen zonder onderbreking.
+- **OAuth**: alleen OAuth werkt nog; het classic token wordt niet meer geaccepteerd.
+
+Overleg met je koppelpartner welke OAuth-methode en welke client authenticatie jullie gebruiken. De mogelijkheden staan hieronder vanaf [OAuth: overzicht](#oauth-overzicht). Een keuzehulp vind je bij [Welke combinatie kies ik?](#welke-combinatie-kies-ik).
+
+### Een classic token gebruiken
+
+Een classic token zoals Profit het genereert, ziet er zo uit:
+
+```xml
1949C1A9CD9AE4797950D94F55A7A4D056770472D4963CB9A8D3800BEE0CCE6A2
```
-Om dit token te kunnen gebruiken in de aanroepen, moet je dit converteren naar Base64. Na conversie ziet het token er bijvoorbeeld zo uit:
+Om het token in een aanroep te gebruiken, converteer je de volledige XML-string naar **Base64**:
-``` xml
-PHRva2VuPjx2ZXJzaW9uPjE8L3ZlcnNpb24+PGRhdGE+QURFMzcwQkU4REFGNDBEMEExN0ZGQjkxNEU0MjY3NUU5OTk4QzJENTQ2QTJGNEZBM0U0RjNBQkZBODY3Qjk2RjwvZGF0YT48L3Rva2VuPg==
+```text
+PHRva2VuPjx2ZXJzaW9uPjE8L3ZlcnNpb24+PGRhdGE+OTQ5QzFBOUNEOUFFNDc5Nzk1MEQ5NEY1NUE3QTREMDU2NzcwNDcyRDQ5NjNDQjlBOEQzODAwQkVFMENDRTZBMjwvZGF0YT48L3Rva2VuPg==
+```
+
+Gebruik dit in de header `Authorization` met het voorvoegsel `AfasToken`:
+
+```bash
+curl -X GET "https://.rest.afas.online/ProfitRestServices/connectors/Profit_Address?skip=0&take=100" \
+ -H "Accept: application/json" \
+ -H "Authorization: AfasToken PHRva2VuPjx2ZXJzaW9uPjE8L3ZlcnNpb24+PGRhdGE+OTQ5QzFBOUNEOUFFNDc5Nzk1MEQ5NEY1NUE3QTREMDU2NzcwNDcyRDQ5NjNDQjlBOEQzODAwQkVFMENDRTZBMjwvZGF0YT48L3Rva2VuPg=="
```
+Behandel een classic token met zorg: het geeft toegang tot gevoelige gegevens. Na het aanmaken kun je het token niet meer in Profit raadplegen. Als je het kwijt bent geraakt, verwijder het dan en maak een nieuw token aan.
+
+---
+
+## OAuth: overzicht
+
+| Client type | Flow | Client authenticatie | Tokenbeveiliging | Beschikbaar vanaf |
+|---|---|---|---|---|
+| Confidential | Client credentials | Client secret | Bearer, optioneel DPoP | Huidige versie |
+| Confidential | Client credentials | Private key JWT | Bearer, optioneel DPoP | **Profit 9** |
+| Confidential | Authorization code + PKCE | Client secret | Bearer, optioneel DPoP | Huidige versie |
+| Confidential | Authorization code + PKCE | Private key JWT | Bearer, optioneel DPoP | **Profit 9** |
+| Public | Authorization code + PKCE | Geen (client heeft geen geheim) | **DPoP** | **Profit 9** |
+
+Vanaf **Profit 9** geldt bovendien dat client secrets een **geldigheidsduur** hebben en periodiek moeten worden vernieuwd. Zie [Geldigheidsduur van client secrets](#geldigheidsduur-van-client-secrets-profit-9).
+
+---
+
+## Begrippen: confidential client en public client
+
+OAuth maakt onderscheid tussen twee soorten clients. Het verschil zit in één vraag: **kan de applicatie een geheim veilig bewaren?**
+
+### Confidential client
+
+Een confidential client draait in een omgeving die jij als leverancier beheert en die niet toegankelijk is voor eindgebruikers, bijvoorbeeld een webserver, een back-end service of een geplande taak op een server. Zo'n applicatie kan een geheim (een client secret of een private key) veilig opslaan.
+
+Omdat de applicatie een geheim heeft, kan Profit bij elke tokenaanvraag controleren **welke applicatie** het token aanvraagt. Dit heet **client authenticatie**.
+
+Voorbeelden:
+- een koppeling tussen Profit en een salarispakket of webshop die op een server draait;
+- een webapplicatie met een eigen back-end die namens een gebruiker gegevens uit Profit ophaalt.
+
+### Public client (Profit 9)
+
+Een public client draait op een apparaat of in een omgeving van de eindgebruiker. De code en configuratie van de applicatie zijn daar in te zien, dus een geheim dat je meelevert is niet geheim meer.
+
+Voorbeelden:
+- een single-page application (SPA) die volledig in de browser draait;
+- een mobiele app;
+- een desktopapplicatie of command-line tool die op de computer van de gebruiker wordt geïnstalleerd.
+
+Een public client **heeft geen client secret** en voert dus geen client authenticatie uit. In plaats daarvan zorgen drie maatregelen voor de beveiliging:
-### Toepassen token
+- de gebruiker logt zelf in bij Profit (Authorization code flow);
+- **PKCE** voorkomt dat een onderschepte authorization code door iemand anders kan worden ingewisseld;
+- **DPoP** koppelt het access token aan een sleutel die alleen de applicatie bezit, zodat een gestolen token onbruikbaar is.
-Het token gebruik je in de HTTP-requestheader met een AfasToken-prefix. Hiervoor gebruik je de header "Authorization" met de waarde van het token:
+> **Vuistregel:** twijfel je? Kan de code van je applicatie door een eindgebruiker worden ingezien of gedownload, dan is het een public client. Zet nooit een client secret of private key in een browserapplicatie, mobiele app of desktopapplicatie.
-``` xml
-AfasToken PHRva2VuPjx2ZXJzaW9uPjE8L3ZlcnNpb24+PGRhdGE+QURFMzcwQkU4REFGNDBEMEExN0ZGQjkxNEU0MjY3NUU5OTk4QzJENTQ2QTJGNEZBM0U0RjNBQkZBODY3Qjk2RjwvZGF0YT48L3Rva2VuPg==
+---
+
+## Endpoints
+
+| Omgeving | Endpoint | URL |
+|---|---|---|
+| Productie | Authorize | `https://.rest.afas.online/ProfitRestServices/oauth/authorize` |
+| Productie | Token | `https://.rest.afas.online/ProfitRestServices/oauth/token` |
+| Test | Authorize | `https://.resttest.afas.online/ProfitRestServices/oauth/authorize` |
+| Test | Token | `https://.resttest.afas.online/ProfitRestServices/oauth/token` |
+| Accept | Authorize | `https://.restaccept.afas.online/ProfitRestServices/oauth/authorize` |
+| Accept | Token | `https://.restaccept.afas.online/ProfitRestServices/oauth/token` |
+
+Vervang `` door het nummer van de Profit-omgeving, bijvoorbeeld `12345`.
+
+---
+
+## Client authenticatie bij een confidential client
+
+Een confidential client bewijst bij elke aanvraag op het token-endpoint wie hij is. Profit ondersteunt hiervoor **twee methoden**. Je kiest per app connector één methode.
+
+| | Client secret | Private key JWT (Profit 9) |
+|---|---|---|
+| Wat deel je met Profit? | Een gedeeld geheim (de secret) | Een X.509-certificaat of de URL van je JWK Set |
+| Wat stuur je mee bij een tokenaanvraag? | De secret zelf | Een kortlevende, ondertekende JWT |
+| Risico bij onderschepping | De secret is herbruikbaar tot deze verloopt of wordt ingetrokken | De JWT is kort geldig en uniek per aanvraag |
+| Beheer | Secret periodiek vernieuwen (Profit 9) | Sleutelpaar beheren en tijdig roteren |
+
+Beide methoden werken met zowel de Client credentials flow als de Authorization code flow. De flow bepaalt **hoe** je een token krijgt; de client authenticatiemethode bepaalt **hoe je aantoont welke applicatie** je bent.
+
+### Methode 1: client secret
+
+Bij het inrichten van de app connector in Profit krijg je een `client_id` en een `client_secret`. Je stuurt beide mee in de body van de tokenaanvraag:
+
+```http
+client_id=
+client_secret=
```
-**Let op**: Behandel het token met zorg, aangezien het toegang biedt tot gevoelige gegevens. Zorg ervoor dat je best practices volgt bij het opslaan en beheren van het token en overweeg je integratie te laten beoordelen door een externe beveiligingsexpert om mogelijke kwetsbaarheden aan te pakken.
+Vanaf **Profit 9** kun je ook `client_secret_basic` gebruiken: stuur beide waarden in de header `Authorization: Basic ` en laat `client_secret` weg uit de body. De waarde vóór Base64 is `FORM_URLENCODE(client_id):FORM_URLENCODE(client_secret)`: codeer **elke waarde afzonderlijk** als `application/x-www-form-urlencoded`, zoals in [RFC 6749 §2.3.1](https://www.rfc-editor.org/rfc/rfc6749#section-2.3.1). Gebruik dus niet zonder meer de ruwe waarden als deze bijvoorbeeld `:` of speciale tekens bevatten. Een `client_id` in de body is toegestaan als deze gelijk is aan die in de Basic-header; weglaten voorkomt verwarring.
+
+Bewaar een client secret zoals je een wachtwoord bewaart: in een secret store of key vault, nooit in broncode, versiebeheer of logbestanden.
+
+#### Geldigheidsduur van client secrets (Profit 9)
+
+Vanaf Profit 9 heeft elke client secret een **geldigheidsduur**. Na de vervaldatum weigert Profit tokenaanvragen met die secret. Je moet de secret dus **periodiek vernieuwen**.
+
+- De geldigheidsduur is instelbaar. De standaardwaarde is 180 dagen.
+- De vervaldatum vind je in het instellingenscherm van de app connector in Profit.
+
+**Zo vernieuw je een secret zonder onderbreking:**
-### Token voor gebruiker genereren via OTP
+1. Genereer in Profit een nieuwe secret voor de app connector.
+2. Zet de nieuwe secret in de configuratie van de koppelende applicatie.
+3. Controleer dat de applicatie met de nieuwe secret tokens kan aanvragen.
+4. Trek de oude secret in.
-AFAS biedt de mogelijkheid om een One Time Password (OTP) te gebruiken voor het verkrijgen van een token. Dit is handig in situaties waarin gebruikers zichzelf moeten registeren in een applicatie.
+> **Tip:** plan het vernieuwen ruim vóór de vervaldatum, bijvoorbeeld als terugkerende taak in je beheerkalender. Een verlopen secret betekent dat de koppeling stopt.
-### Unauthorized
+Wil je niet periodiek secrets uitwisselen? Gebruik dan [private key JWT](#methode-2-private-key-jwt-profit-9).
-Wanneer het token niet geldig is of je deze niet correct toepast, krijg je HTTP 401 als response. Vraag een nieuw token aan of valideer of je het token correct converteert. Gebruik de tooling op [connect.afas.nl](https://connect.afas.nl) om te valideren of je de request correct uitvoert.
+### Methode 2: private key JWT (Profit 9)
+Bij private key JWT gebruik je een **asymmetrisch sleutelpaar**:
+- de **private key** blijft altijd bij jou en verlaat je server nooit;
+- de publieke sleutel bied je aan via een X.509-certificaat in Profit of via een JWK Set op een eigen URL.
-## OAuth
+In plaats van een secret stuur je bij elke tokenaanvraag een kortlevende JWT mee (een *client assertion*) die je ondertekent met je private key. Profit controleert de handtekening met de publieke key. Er gaat dus nooit een herbruikbaar geheim over de lijn. Deze methode is gebaseerd op [RFC 7523](https://www.rfc-editor.org/rfc/rfc7523) en de `private_key_jwt`-methode uit OpenID Connect.
-Binnen het OAuth protocol ondersteunen we twee typen flows:
-1. Client credentials flow
-2. Authorization code flow with PKCE
+Profit ondersteunt `RS256`, `PS256` en `ES256`. Gebruik voor RSA minimaal 2048 bits. Gebruik voor EC de curve P-256. Deze eisen gelden voor beide sleutelbronnen.
+#### Stap 1: sleutelpaar aanmaken en publieke sleutel aanbieden
-### Client credential flow
+Maak bijvoorbeeld met OpenSSL een RSA-sleutelpaar en een X.509-certificaat aan:
-De Client Credentials Flow wordt voornamelijk gebruikt voor server-to-server communicatie, waarbij er geen directe betrokkenheid van een eindgebruiker is. Dit type flow is ideaal voor applicaties die namens zichzelf toegang willen tot resources in plaats van namens een gebruiker. Het is geschikt voor situaties waarin een applicatie toegang nodig heeft tot API's om bijvoorbeeld achtergrondprocessen uit te voeren, zoals het synchroniseren van gegevens of het uitvoeren van batchverwerkingen.
+```bash
+# RSA-sleutelpaar (2048 bits of meer)
+openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out private_key.pem
+openssl rsa -in private_key.pem -pubout -out public_key.pem
+openssl req -new -x509 -key private_key.pem -out certificate.pem -days 3650 -subj "/CN=mijn-app"
+```
+
+De private key (`private_key.pem`) blijft bij jou en gaat nooit naar Profit. Je kunt de publieke sleutel op twee manieren aan Profit aanbieden:
+
+1. **Bestand:** upload `certificate.pem` bij de app connector. Een losse publieke sleutel, zoals `public_key.pem`, is niet voldoende. Je kunt bij het certificaat optioneel een key ID invullen. Laat je dit veld leeg, dan gebruikt Profit de SHA-1-thumbprint van het certificaat als key ID.
+2. **JWKS-URL (`jwks_uri`):** publiceer een JWK Set op een HTTPS-URL en registreer deze URL bij de app connector. Profit haalt de publieke sleutel dan automatisch op. De URL mag niet redirecten en de response mag maximaal 64 KB zijn. Iedere gebruikte sleutel heeft `"use": "sig"`. Bevat de set meer dan één sleutel, dan is `kid` verplicht.
+
+Profit cachet een JWK Set standaard één uur en volgt de `Cache-Control`-header. Bij een onbekende `kid` haalt Profit de set direct opnieuw op, zodat een nieuwe sleutel vrijwel meteen bruikbaar is. Houd er bij verwijderen rekening mee dat een gecachte sleutel nog maximaal een uur kan worden geaccepteerd.
+
+#### Best practices voor partners met meerdere klanten
+
+Beheer je koppelingen voor meerdere klanten, gebruik dan **per klantomgeving een eigen sleutelpaar**. Gebruik in ieder geval aparte sleutels voor productie en test of acceptatie. Hergebruik niet één private key voor alle klanten: als die sleutel uitlekt, kunnen alle app connectoren die de bijbehorende publieke sleutel vertrouwen risico lopen.
+
+De **private key** is geheim en moet veilig, per klantomgeving, worden opgeslagen. Het certificaat of de publieke sleutel mag met Profit worden gedeeld; de private key verlaat de beveiligde omgeving van de partner niet. Beperk ook intern wie de private keys kan gebruiken.
+
+Gebruik je een JWKS-URL, publiceer dan per klantomgeving een eigen JWK Set met alleen de publieke sleutel(s) van die klant en registreer de bijbehorende URL bij de app connector van die klant. Gebruik niet voor meerdere klanten dezelfde JWK Set met daarin de sleutels van al die klanten. Zo voorkom je dat een sleutel die voor één klant bedoeld is, ook bij andere klanten wordt vertrouwd.
+
+#### Stap 2: client assertion opstellen
+
+De JWT bevat de volgende gegevens:
+
+**Header**
+
+```json
+{
+ "alg": "RS256",
+ "typ": "JWT",
+ "kid": ""
+}
+```
-Wanneer een app connector gebruikmaakt van de Client Credentials Flow, worden er een 'OAuth client id' en een 'OAuth client secret' aangemaakt. Hiervoor volg je de stappen in [Eigen app connector inrichten in vogelvlucht (OAuth-token)](https://help.afas.nl/help/NL/SE/120718.htm).
-De OAuth client secret wordt eenmalig verstrekt tijdens het aanmaken en kan daarna niet meer worden opgevraagd.
+De header `kid` is optioneel als je certificaten als bestand opslaat. Stuur je `kid` mee, dan moet deze exact overeenkomen met de key ID die bij het certificaat in Profit staat, of met de SHA-1-thumbprint als je dat veld leeg hebt gelaten. Een onbekende `kid` wordt afgewezen, ook als het certificaat verder geldig is. Zonder `kid` kun je `x5t#S256` meesturen of beide identificerende headers weglaten; Profit probeert dan alle geldige certificaten van de app connector. Een `kid` beperkt dit zoekwerk en kan daarom performancewinst opleveren.
+Bij een JWKS-URL komt `kid` uit de JWK Set. Als de set meer dan één sleutel bevat, moet de assertion een `kid` bevatten. Deze waarde moet exact overeenkomen met een sleutel in de set.
-#### Stappen voor toegang tot de API
+> **Praktische kanttekening:** veel JWT-libraries vullen `kid` automatisch als de signing key een key ID heeft. Wil je de thumbprint niet als `kid` gebruiken, vul dan in Profit dezelfde key ID in als je library verstuurt, of maak de key ID in de library leeg.
-Om toegang te krijgen tot de API, volg je de volgende stappen:
-1. Access token ophalen
- 1. Roep het [token endpoint](#token-endpoint) (POST) aan met de volgende informatie in de body:
- 1. grant_type: client_credentials
- 2. client_id: ``
- 3. client_secret: ``
- 2. In de response van deze aanroep vind je de volgende velden:
- 1. access_token: de access token die je in de Authorization header moet toevoegen.
- 2. refresh_token: is bij de client credentials flow altijd "null".
- 3. token_type: Bearer
- 4. expires_in: geldigheid van het access token in seconden.
- 3. Access token gebruiken
- 1. Kopieer de access token, zet er 'Bearer' voor, en voeg hem toe in je Authorization header.
+**Payload**
-#### cURL voorbeelden
+| Claim | Waarde |
+|---|---|
+| `iss` | Je `client_id` |
+| `sub` | Je `client_id` |
+| `aud` | Bij `typ: JWT`: de URL van het token-endpoint of de basis-URL. Bij `typ: client-authentication+jwt`: de basis-URL, dus de token-URL zonder `/oauth/token`. |
+| `jti` | Een unieke, willekeurige waarde per JWT (bijvoorbeeld een GUID). Profit wijst hergebruik van een `jti` af. |
+| `iat` | Tijdstip van aanmaken (Unix timestamp) |
+| `exp` | Vervaltijdstip (Unix timestamp). Gebruik bij voorkeur 60 seconden na `iat`. Het maximum is 5 minuten, met 60 seconden toegestane klokafwijking. |
+
+```json
+{
+ "iss": "",
+ "sub": "",
+ "aud": "https://.rest.afas.online/ProfitRestServices/oauth/token",
+ "jti": "5f1d3b2a-8c4e-4b8e-9a61-2f0d7c3e9b14",
+ "iat": 1790000000,
+ "exp": 1790000300
+}
+```
+
+Onderteken de JWT met je private key.
+
+#### Stap 3: client assertion meesturen
+
+In de tokenaanvraag vervang je `client_secret` door:
+
+```http
+client_id=
+client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
+client_assertion=
+```
+
+Maak voor elke tokenaanvraag een nieuwe JWT aan.
+
+Stuur precies één client-authenticatiemethode mee. Vanaf Profit 9 geeft een aanvraag met zowel `client_secret` als `client_assertion`, of met Basic-auth en een credential in de body, `invalid_request`. Dat geldt ook voor een `client_id` in de body die afwijkt van de Basic-header.
+
+Vanaf Profit 9 geeft een onbekende client of mislukte client-authenticatie `invalid_client`. Bij mislukte Basic-auth antwoordt Profit met HTTP `401` en `WWW-Authenticate: Basic`; bij de andere methoden is dit HTTP `400`. Ook een onjuist client secret wordt afgewezen.
+
+#### Sleutels roteren
+
+Ook sleutelparen vervang je periodiek. Werkwijze:
+
+1. Maak voor de betreffende klantomgeving een nieuw sleutelpaar aan en registreer het nieuwe certificaat of voeg de nieuwe sleutel aan de JWK Set van die klant toe. Meerdere certificaten kunnen tegelijk actief zijn.
+2. Laat je applicatie JWT's ondertekenen met de nieuwe private key (met de bijbehorende `kid`).
+3. Controleer dat de nieuwe sleutel werkt. Houd daarna het oude certificaat of de oude JWK nog een redelijke, afgesproken overlapperiode beschikbaar, zodat de overgang soepel verloopt. Bij een JWKS-URL blijft de oude sleutel gedurende die periode in de JWK Set staan. Houd rekening met de cacheduur: Profit cachet de set standaard één uur en volgt de `Cache-Control`-header.
+4. Verwijder na de overlapperiode het oude certificaat of de oude JWK.
+
+Tijdens de overstap en overlapperiode zijn er dus tijdelijk twee sleutels actief voor die klantomgeving. Dit sluit aan bij het [sleutelrotatieadvies in OpenID Connect Core](https://openid.net/specs/openid-connect-core-1_0.html#RotateSigKeys). Vermoed je dat een private key is uitgelekt, trek dan de bijbehorende publieke sleutel direct in Profit of uit de JWK Set in en vervang de sleutel; wacht in dat geval niet op het einde van de overlapperiode. Controleer ook of de betreffende app connectoren en klantomgevingen afzonderlijke sleutels gebruiken.
+
+### Fouten bij tokenaanvragen (Profit 9)
+
+Een ontbrekende `grant_type` geeft `invalid_request`. Bij het inwisselen van een authorization code geldt dat ook voor een ontbrekende `code`, `redirect_uri` of `code_verifier`. Herhaal geen parameters in de tokenaanvraag: meerdere waarden voor dezelfde parameter geven `invalid_request` in plaats van samengevoegde waarden. Profit laat `error_description` weg als er geen beschrijving is; verwacht dan geen veld met `null`.
+
+---
+
+## Confidential client: Client credentials flow
+
+Gebruik deze flow voor **automatische koppelingen tussen twee systemen**. Er logt geen gebruiker in: de applicatie vraagt zelf een token aan en werkt onder de gebruiker die bij de app connector is ingesteld. Voor de meeste koppelingen is dit de juiste keuze.
+
+```
+Applicatie Profit
+ | |
+ |-- POST /oauth/token --------------------> |
+ | grant_type=client_credentials |
+ | + client authenticatie |
+ | |
+ |<-- access_token -------------------------- |
+ | |
+ |-- GET /connectors/... (Bearer token) ---> |
+```
+
+### Token aanvragen met client secret
-**Token ophalen:**
```bash
curl -X POST https://.rest.afas.online/ProfitRestServices/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
@@ -102,123 +345,376 @@ curl -X POST https://.rest.afas.online/ProfitRestServices/oauth
-d "client_secret="
```
-**Response voorbeeld:**
+### Token aanvragen met private key JWT (Profit 9)
+
+```bash
+curl -X POST https://.rest.afas.online/ProfitRestServices/oauth/token \
+ -H "Content-Type: application/x-www-form-urlencoded" \
+ -d "grant_type=client_credentials" \
+ -d "client_id=" \
+ -d "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer" \
+ -d "client_assertion="
+```
+
+### Antwoord (Profit 9)
+
```json
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
- "refresh_token": null,
"token_type": "Bearer",
"expires_in": 3600
}
```
-**API aanroep met token:**
+Het access token is `expires_in` seconden geldig. Bij deze flow krijg je geen refresh token; vanaf Profit 9 ontbreekt daarom het veld `refresh_token` in het antwoord. Vraag na afloop van de geldigheid simpelweg opnieuw een token aan. Hergebruik het token zolang het geldig is, in plaats van voor elke aanroep een nieuw token op te halen.
+
+---
+
+## Confidential client: Authorization code flow met PKCE
+
+Gebruik deze flow wanneer een applicatie met een eigen back-end **namens een gebruiker** gegevens uit Profit ophaalt of bijwerkt. De gebruiker logt in bij Profit. Profit controleert vervolgens of de gebruiker lid is van de autorisatiegroep die aan de app connector is gekoppeld.
+
+Profit vereist bij deze flow **PKCE** (Proof Key for Code Exchange, [RFC 7636](https://www.rfc-editor.org/rfc/rfc7636)). PKCE zorgt ervoor dat alleen de applicatie die de inlog heeft gestart, de authorization code kan inwisselen voor een token.
+
+```
+Gebruiker Applicatie (back-end) Profit
+ | | |
+ | 1. Inloggen --> | |
+ | | 2. code_verifier + challenge |
+ |<-- 3. redirect naar /oauth/authorize ------------->|
+ |------------------ 4. gebruiker logt in ----------->|
+ |<-- 5. redirect naar redirect_uri met code ---------|
+ |----------------> | |
+ | | 6. POST /oauth/token |
+ | | code + code_verifier |
+ | | + client authenticatie ----->|
+ | |<-- 7. access_token -------------|
+```
+
+### Stap 1: code verifier en code challenge maken
+
+- **`code_verifier`**: een willekeurige string van 43 tot 128 tekens (letters, cijfers en `-._~`). Bewaar deze tijdelijk op de server, gekoppeld aan de sessie van de gebruiker.
+- **`code_challenge`**: de SHA-256-hash van de `code_verifier`, Base64URL-gecodeerd zonder `=`-opvulling.
+
+```text
+code_challenge = BASE64URL( SHA256( code_verifier ) )
+```
+
+Maak daarnaast een willekeurige **`state`** aan. Hiermee controleer je in stap 3 dat het antwoord bij de aanvraag hoort die je zelf hebt gestart. Dit beschermt tegen CSRF.
+
+### Stap 2: gebruiker doorsturen naar Profit
+
+Stuur de browser van de gebruiker naar het authorize-endpoint:
+
+```text
+https://.rest.afas.online/ProfitRestServices/oauth/authorize
+ ?response_type=code
+ &client_id=
+ &redirect_uri=
+ &state=
+ &code_challenge=
+ &code_challenge_method=S256
+```
+
+| Parameter | Omschrijving |
+|---|---|
+| `response_type` | Altijd `code` |
+| `client_id` | De client ID van de app connector |
+| `redirect_uri` | De URL waarnaar Profit de gebruiker terugstuurt. Deze moet overeenkomen met de redirect URL die bij de app connector is ingesteld. Vanaf Profit 9 vergelijkt Profit het scheme en de host zonder onderscheid tussen hoofd- en kleine letters; het pad, de query en de poort moeten exact overeenkomen. |
+| `state` | Willekeurige waarde die je zelf controleert bij de terugkeer |
+| `code_challenge` | De code challenge uit stap 1 |
+| `code_challenge_method` | Altijd `S256` |
+
+### Stap 3: authorization code ontvangen
+
+Na het inloggen stuurt Profit de gebruiker terug naar je `redirect_uri`:
+
+```text
+?code=&state=
+```
+
+Controleer dat `state` gelijk is aan de waarde uit stap 1. Als dat niet zo is, breek je het proces af. De authorization code is kort geldig en eenmalig te gebruiken.
+
+Vanaf **Profit 9** stuurt Profit fouten die optreden nadat de `redirect_uri` is gevalideerd, terug via een redirect naar die URL, inclusief een niet-lege meegegeven `state`. Zo geeft `response_type=token` de fout `unsupported_response_type` via de redirect terug. Ook een onverwachte fout, bijvoorbeeld bij de uitwisseling met de STS, komt als `error=server_error` via een redirect terug in plaats van als HTTP `500` op het authorize-endpoint. Controleer ook bij een foutredirect de `state` voordat je de fout verwerkt. Als de `redirect_uri` niet is gevalideerd, wordt er niet naar die URL doorgestuurd.
+
+Een lege `state` wordt niet in het antwoord opgenomen. Ook een lege `nonce` wordt niet als waarde in het antwoord opgenomen. Verstuur voor `state` altijd een niet-lege willekeurige waarde, zodat je een antwoord kunt koppelen aan de oorspronkelijke aanvraag.
+
+### Stap 4: code inwisselen voor een token
+
+Wissel de code in via je **back-end**, met de `code_verifier` uit stap 1 en je client authenticatie.
+
+**Met client secret:**
+
```bash
-curl -X GET "https://.rest.afas.online/ProfitRestServices/connectors/Profit_Address?skip=0&take=100" \
- -H "Accept: application/json" \
- -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
+curl -X POST https://.rest.afas.online/ProfitRestServices/oauth/token \
+ -H "Content-Type: application/x-www-form-urlencoded" \
+ -d "grant_type=authorization_code" \
+ -d "code=" \
+ -d "redirect_uri=" \
+ -d "code_verifier=" \
+ -d "client_id=" \
+ -d "client_secret="
+```
+
+**Met private key JWT (Profit 9):**
+
+```bash
+curl -X POST https://.rest.afas.online/ProfitRestServices/oauth/token \
+ -H "Content-Type: application/x-www-form-urlencoded" \
+ -d "grant_type=authorization_code" \
+ -d "code=" \
+ -d "redirect_uri=" \
+ -d "code_verifier=" \
+ -d "client_id=" \
+ -d "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer" \
+ -d "client_assertion="
```
-**Response voorbeeld:**
+**Antwoord (Profit 9):**
+
```json
{
- "skip": 0,
- "take": 100,
- "rows": [
- {
- "AddressId": 1,
- "AddressLine": "Stadsring 69, 3811 HN AMERSFOORT",
- "PoBox": false,
- "Address": "Stadsring",
- "Number": 69,
- "ZipCode": "3811 HN",
- "Recidence": "Amersfoort",
- "Country": "NL"
- }
- ]
+ "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
+ "refresh_token": "",
+ "token_type": "Bearer",
+ "expires_in": 3600
}
```
-### Authorization code flow with PKCE
-
-De Authorization Code Flow with PKCE is ideaal voor webapplicaties die namens een gebruiker toegang tot resources moeten verkrijgen. Dit proces begint met gebruikersauthenticatie en autorisatie, waarbij de gebruiker inlogt en toestemming geeft. Vervolgens wordt een autorisatiecode verstrekt, die kan worden ingewisseld voor een access token. Deze flow biedt een veilige manier om toegang te krijgen tot gegevens bij externe services, doordat het de betrokkenheid van de gebruiker vereist voordat toegang wordt verleend.
-
-> De redirect_uri moet overeenkomen met één van de redirect url's die je hebt geregistreerd in de AppConnector. Om te testen op AFAS Connect moet je https://connect.afas.nl/oauth/callback hebben geregistreerd. Na aanmaken van een AppConnector kun je meerdere redirect url's registreren.
-
-
-#### Stappen voor Toegang tot de API
-
-Om toegang te krijgen tot de API via de Authorization Code Flow, volg je de volgende stappen:
-1. Verkrijg een Autorisatiecode
- 1. Leid de gebruiker naar het [autorisatie endpoint](#authorization-endpoint) (GET) met de volgende parameters:
- 1. response_type: code
- 2. client_id: ``
- 3. redirect_uri: ``
- 4. state: ``
- 5. code_challenge: ``
- 6. code_challenge_method: ``
- 2. De gebruiker logt in en geeft toestemming. Na toestemming wordt de gebruiker teruggeleid naar de opgegeven redirect_uri met een autorisatiecode.
-2. Wissel de Autorisatiecode in voor een Access Token
- 1. Roep het [token endpoint](#token-endpoint) (POST) aan met de volgende informatie in de body:
- 1. grant_type: authorization_code
- 2. code: ``
- 3. redirect_uri: ``
- 4. client_id: ``
- 5. client_secret: ``
- 6. code_verifier: ``
-3. In de response van deze aanroep vind je de volgende velden:
- 1. access_token: de access token die je in de Authorization header moet toevoegen.
- 2. refresh_token: een token dat kan worden gebruikt om een nieuw access token te verkrijgen.
- 3. token_type: Bearer
- 4. expires_in: geldigheid van het access token in seconden.
-4. Access Token gebruiken
- 1. Kopieer de access token, zet er 'Bearer' voor, en voeg hem toe in je Authorization header.
-5. Een nieuw access token en refresh token ophalen
- 1. Roep het [token endpoint](#token-endpoint) (POST) aan met de volgende informatie in de body:
- 1. grant_type: refresh_token
- 2. refresh_token: ``
- 3. client_id: ``
- 4. client_secret: ``
- 2. In de response van deze aanroep vind je dezelfde velden als bij stap 3.
-
-#### cURL voorbeelden
-
-**Stap 1: Gebruiker redirecten naar autorisatie endpoint:**
+Het refresh token kun je maar **één keer** gebruiken. Zodra je het inwisselt voor een nieuw access token, wordt het ongeldig en krijg je in het antwoord een nieuw refresh token terug. Sla dat nieuwe refresh token direct op en gebruik het bij de volgende vernieuwing. Gebruik je per ongeluk een oud refresh token, dan weigert Profit de aanvraag.
+
+De refresh tokens zijn samen maximaal **30 dagen** geldig, gerekend vanaf het moment dat het **eerste** refresh token is uitgegeven. Een nieuw refresh token verlengt die termijn niet. Na 30 dagen moet de gebruiker dus opnieuw inloggen via de Authorization code flow. Houd hier rekening mee in je applicatie: vang de foutmelding bij een verlopen refresh token af en stuur de gebruiker dan opnieuw naar het authorize-endpoint.
+
+### Stap 5: token vernieuwen met een refresh token
+
```bash
-# Open deze URL in een browser:
-https://.rest.afas.online/ProfitRestServices/oauth/authorize?response_type=code&client_id=&redirect_uri=&state=&code_challenge=&code_challenge_method=S256
+curl -X POST https://.rest.afas.online/ProfitRestServices/oauth/token \
+ -H "Content-Type: application/x-www-form-urlencoded" \
+ -d "grant_type=refresh_token" \
+ -d "refresh_token=" \
+ -d "client_id=" \
+ -d "client_secret="
+```
+
+Gebruik je private key JWT, stuur dan `client_assertion_type` en `client_assertion` mee in plaats van `client_secret`.
+
+---
+
+## Public client: Authorization code flow met PKCE en DPoP (Profit 9)
+
+Vanaf Profit 9 kun je een app connector inrichten als **public client**. Dit is bedoeld voor browserapplicaties (SPA's), mobiele apps en desktopapplicaties die namens een gebruiker met Profit werken, zonder eigen back-end.
+
+Een public client:
+
+- gebruikt de **Authorization code flow met PKCE** (verplicht);
+- stuurt **geen** `client_secret` of `client_assertion` mee;
+- beveiligt het access token met **DPoP** (verplicht).
+
+### Wat is DPoP?
+
+Een gewoon (*Bearer*) access token werkt als een toegangspas: wie het token heeft, kan het gebruiken. Voor een server is dat acceptabel, maar in een browser of op een apparaat van een gebruiker is de kans groter dat een token uitlekt, bijvoorbeeld via een kwetsbaarheid in de webpagina of malware.
+
+**DPoP** (Demonstrating Proof of Possession, [RFC 9449](https://www.rfc-editor.org/rfc/rfc9449)) koppelt het access token aan een sleutelpaar dat de applicatie zelf aanmaakt:
+
+1. De applicatie maakt bij het opstarten een sleutelpaar aan. De private key blijft in de applicatie (in de browser bij voorkeur als *non-extractable* key via de Web Crypto API).
+2. Bij elke aanvraag stuurt de applicatie een **DPoP proof** mee: een kleine JWT die ondertekend is met de private key en die hoort bij precies die ene aanvraag.
+3. Profit koppelt het uitgegeven access token aan de publieke key van de applicatie.
+4. Bij elke API-aanroep controleert Profit of de DPoP proof is ondertekend met dezelfde sleutel.
+
+Het resultaat: een gestolen access token is zonder de bijbehorende private key onbruikbaar.
+
+### Stap 1: sleutelpaar aanmaken
+
+Maak een sleutelpaar aan voor de duur van de sessie. Voorbeeld in de browser:
+
+```javascript
+const keyPair = await crypto.subtle.generateKey(
+ { name: "ECDSA", namedCurve: "P-256" },
+ false, // private key niet exporteerbaar
+ ["sign", "verify"]
+);
+```
+
+### Stap 2: code verifier, code challenge en state maken
+
+Dit werkt hetzelfde als bij de [confidential client](#stap-1-code-verifier-en-code-challenge-maken). Bewaar de `code_verifier` en `state` in het geheugen van de applicatie (bijvoorbeeld `sessionStorage` bij een SPA).
+
+### Stap 3: gebruiker doorsturen naar Profit
+
+```text
+https://.rest.afas.online/ProfitRestServices/oauth/authorize
+ ?response_type=code
+ &client_id=
+ &redirect_uri=
+ &state=
+ &code_challenge=
+ &code_challenge_method=S256
+```
+
+### Stap 4: DPoP proof maken voor de tokenaanvraag
+
+Een DPoP proof is een JWT met de volgende opbouw:
+
+**Header**
+
+```json
+{
+ "typ": "dpop+jwt",
+ "alg": "ES256",
+ "jwk": {
+ "kty": "EC",
+ "crv": "P-256",
+ "x": "",
+ "y": ""
+ }
+}
+```
+
+De header bevat de **publieke** key van de applicatie (`jwk`).
+
+**Payload**
+
+| Claim | Waarde |
+|---|---|
+| `jti` | Unieke, willekeurige waarde per proof |
+| `htm` | De HTTP-methode van de aanvraag, bijvoorbeeld `POST` |
+| `htu` | De URL van de aanvraag, zonder querystring |
+| `iat` | Tijdstip van aanmaken (Unix timestamp) |
+| `nonce` | De laatst ontvangen waarde uit de header `DPoP-Nonce` (zie [DPoP nonce](#dpop-nonce)) |
+
+```json
+{
+ "jti": "e1f3c9a2-4b7d-4c1e-8f2a-9d6b3e5a7c10",
+ "htm": "POST",
+ "htu": "https://.rest.afas.online/ProfitRestServices/oauth/token",
+ "iat": 1790000000
+}
```
-**Stap 2: Token ophalen met autorisatiecode:**
+Onderteken de proof met de private key uit stap 1. Maak voor elke aanvraag een nieuwe proof.
+
+### Stap 5: code inwisselen voor een token
+
+Gebruik een eerder ontvangen geldige nonce in je DPoP proof. Heb je die nog niet, voer dan eerst de nonce-uitwisseling uit zoals beschreven bij [DPoP nonce](#dpop-nonce) en herhaal de aanvraag met een nieuwe proof.
+
```bash
curl -X POST https://.rest.afas.online/ProfitRestServices/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
+ -H "DPoP: " \
-d "grant_type=authorization_code" \
-d "code=" \
-d "redirect_uri=" \
- -d "client_id=" \
- -d "client_secret=" \
- -d "code_verifier="
+ -d "code_verifier=" \
+ -d "client_id="
```
-**Response voorbeeld:**
+Let op: er is **geen** `client_secret` en **geen** `client_assertion`.
+
+**Antwoord (Profit 9):**
+
```json
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
- "token_type": "Bearer",
- "expires_in": 3600,
- "refresh_token": "50c90d85-a7aa-4e8a-a9b8-..."
+ "refresh_token": "",
+ "token_type": "DPoP",
+ "expires_in": 3600
+}
+```
+
+Het `token_type` is `DPoP` in plaats van `Bearer`. Dit token werkt alleen in combinatie met een geldige DPoP proof.
+
+### Stap 6: connector aanroepen met een DPoP-token
+
+Bij elke API-aanroep stuur je twee headers mee:
+
+- `Authorization: DPoP ` (let op: `DPoP` in plaats van `Bearer`);
+- `DPoP: ` met een nieuwe proof voor deze aanroep.
+
+De proof voor een API-aanroep bevat naast `jti`, `htm`, `htu` en `iat` ook de claim **`ath`**: de SHA-256-hash van het access token, Base64URL-gecodeerd.
+
+```json
+{
+ "jti": "7a2c4e6f-1b3d-4f5a-9c8e-2d4f6a8b0c1e",
+ "htm": "GET",
+ "htu": "https://.rest.afas.online/ProfitRestServices/connectors/Profit_Address",
+ "iat": 1790000060,
+ "ath": ""
+}
+```
+
+```bash
+curl -X GET "https://.rest.afas.online/ProfitRestServices/connectors/Profit_Address?skip=0&take=100" \
+ -H "Accept: application/json" \
+ -H "Authorization: DPoP eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." \
+ -H "DPoP: "
+```
+
+### Stap 7: token vernieuwen
+
+Ook bij het vernieuwen stuur je een DPoP proof mee, ondertekend met **dezelfde** sleutel als bij de oorspronkelijke tokenaanvraag.
+
+```bash
+curl -X POST https://.rest.afas.online/ProfitRestServices/oauth/token \
+ -H "Content-Type: application/x-www-form-urlencoded" \
+ -H "DPoP: " \
+ -d "grant_type=refresh_token" \
+ -d "refresh_token=" \
+ -d "client_id="
+```
+
+### DPoP nonce
+
+Profit vereist een door de server bepaalde waarde (*nonce*) in de DPoP proof. Heb je nog geen geldige nonce ontvangen, stuur dan een aanvraag zonder nonce. Profit antwoordt met `use_dpop_nonce` en de header `DPoP-Nonce`. Heb je al een geldige nonce bewaard, dan kun je die direct gebruiken.
+
+Profit controleert de nonce voordat een eventuele client assertion wordt gevalideerd. Daardoor wordt een aanvraag met client assertion bij de nonce-challenge nog niet als gebruikt gemarkeerd en wordt de herhaalde aanvraag niet als replay afgewezen. De nonce-verplichting geldt voor de hele server; de challenge onthult dus niets over de betreffende client.
+
+```http
+HTTP/1.1 400 Bad Request
+DPoP-Nonce:
+Content-Type: application/json
+
+{
+ "error": "use_dpop_nonce"
+}
+```
+
+Maak daarna een volledig nieuwe DPoP proof met een nieuwe `jti`, neem de ontvangen nonce op en herhaal de oorspronkelijke aanvraag:
+
+```json
+{
+ "jti": "7c4b96d1-76d8-42ea-9508-f2c5944e50d8",
+ "htm": "POST",
+ "htu": "https://.rest.afas.online/ProfitRestServices/oauth/token",
+ "iat": 1790000001,
+ "nonce": ""
}
```
-**Stap 3: API aanroep met token:**
+```bash
+curl -X POST https://.rest.afas.online/ProfitRestServices/oauth/token \
+ -H "Content-Type: application/x-www-form-urlencoded" \
+ -H "DPoP: " \
+ -d "grant_type=authorization_code" \
+ -d "code=" \
+ -d "redirect_uri=" \
+ -d "code_verifier=" \
+ -d "client_id="
+```
+
+Profit kan bij iedere response een nieuwe `DPoP-Nonce` terugsturen, ook bij een geslaagde aanvraag met status `200`. Bewaar daarom na iedere response de meest recent ontvangen nonce en gebruik deze in de eerstvolgende DPoP proof.
+
+---
+
+## Een connector aanroepen met het access token
+
+Voor confidential clients (Bearer-token):
+
```bash
curl -X GET "https://.rest.afas.online/ProfitRestServices/connectors/Profit_Address?skip=0&take=100" \
-H "Accept: application/json" \
-H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
```
-**Response voorbeeld:**
```json
{
"skip": 0,
@@ -226,7 +722,7 @@ curl -X GET "https://.rest.afas.online/ProfitRestServices/conne
"rows": [
{
"AddressId": 1,
- "AddressLine": "Stadsring 69, 3811 HN AMERSFOORT",
+ "AddressLine": "Stadsring 69, 3811 HN AMERSFOORT",
"PoBox": false,
"Address": "Stadsring",
"Number": 69,
@@ -238,29 +734,55 @@ curl -X GET "https://.rest.afas.online/ProfitRestServices/conne
}
```
-### OAuth & SOAP API
-Bovenstaande beschrijving voor beide flows geldt **ook wanneer je gebruikmaakt van de SOAP API**. Het is belangrijk dat je het Bearer-token meegeeft in de header en niet in de body.
+Voor public clients (DPoP-token): zie [Stap 6](#stap-6-connector-aanroepen-met-een-dpop-token).
+
+Vanaf **Profit 9** geeft de resource server bij HTTP `401` altijd een `WWW-Authenticate`-header mee. Wat daarin staat, hangt af van de aanvraag:
+
+- **Geen credentials meegestuurd:** Profit biedt zowel `Bearer` als `DPoP` aan, zonder foutcode. Dit betekent dat je een access token moet meesturen.
+- **Bearer-token afgewezen:** de header bevat `Bearer error="invalid_token"`. Vraag een nieuw token aan of controleer het meegestuurde token.
+- **DPoP-token afgewezen:** de header bevat `DPoP error="invalid_token"`. Dit geldt ook als de proof met een andere sleutel is ondertekend dan die waaraan het token is gebonden ([RFC 9449 §7.1](https://www.rfc-editor.org/rfc/rfc9449#section-7.1)). Controleer het token en de proof.
+
+Is de opslag voor replay-detectie tijdelijk niet beschikbaar, dan geeft Profit HTTP `503` in plaats van `401`. Dat is een serverstoring, geen aanwijzing dat je token ongeldig is.
+Je kunt alleen de Get- en UpdateConnectoren aanroepen die aan de app connector zijn gekoppeld.
-### Token endpoint
-Deze endpoints gelden zowel voor REST als voor SOAP.
+De OAuth-flows hierboven gelden ook voor de SOAP API. Volg de HTTP-headervereisten van het gekozen tokentype; plaats het access token niet in de SOAP-body.
-**Productie**: https://``.rest.afas.online/ProfitRestServices/oauth/token
+---
+
+## Welke combinatie kies ik?
-**Accept**: https://``.restaccept.afas.online/ProfitRestServices/oauth/token
+1. **Logt er een gebruiker in?**
+ - **Nee** → confidential client met de **Client credentials flow**.
+ - **Ja** → ga naar vraag 2.
+2. **Heeft je applicatie een eigen server (back-end) waar je een geheim veilig kunt bewaren?**
+ - **Ja** → confidential client met de **Authorization code flow met PKCE**.
+ - **Nee** (SPA, mobiele app, desktopapplicatie) → **public client** met de **Authorization code flow met PKCE en DPoP** (Profit 9).
+3. **Confidential client: welke client authenticatie?**
+ - **Private key JWT** (Profit 9) heeft de voorkeur: er gaat geen herbruikbaar geheim over de lijn en je hoeft geen secrets met AFAS of je klant uit te wisselen.
+ - **Client secret** is eenvoudiger te implementeren, maar vanaf Profit 9 moet je de secret periodiek vernieuwen.
+
+---
-**Test**: https://``.resttest.afas.online/ProfitRestServices/oauth/token
+## Veelgestelde vragen
-### Authorization endpoint
-Deze endpoints gelden zowel voor REST als voor SOAP.
+**Moet ik mijn bestaande koppeling aanpassen voor Profit 9?**
+Gebruik je een client secret, dan krijgt deze vanaf Profit 9 een geldigheidsduur. Zorg dat je een proces hebt om de secret tijdig te vernieuwen, of stap over op private key JWT. Verder blijven de Client credentials flow en de Authorization code flow met PKCE werken zoals voorheen.
-**Productie**: https://``.rest.afas.online/ProfitRestServices/oauth/authorize
+**Ik gebruik nog een classic token. Wat moet ik doen?**
+Stap vóór 31 augustus 2027 over op OAuth. Bestaande classic tokens verlopen al op 1 mei 2027. Vanaf Profit 9 kun je hiervoor het authenticatietype van je bestaande app connector wijzigen. Zie [Overstappen naar OAuth](#overstappen-naar-oauth-profit-9).
-**Accept**: https://``.restaccept.afas.online/ProfitRestServices/oauth/authorize
+**Kan ik een client secret gebruiken in mijn mobiele app of browserapplicatie?**
+Nee. Een geheim in een applicatie die bij de gebruiker draait, is uit te lezen. Richt de app connector vanaf Profit 9 in als public client en gebruik PKCE en DPoP.
-**Test**: https://``.resttest.afas.online/ProfitRestServices/oauth/authorize
+**Wat is het verschil tussen PKCE en DPoP?**
+PKCE beschermt de **authorization code**: alleen de applicatie die de inlog startte, kan de code inwisselen. DPoP beschermt het **access token**: alleen de applicatie die de private key bezit, kan het token gebruiken. Een public client heeft beide nodig.
+**Wat is het verschil tussen de flow en de client authenticatie?**
+De flow (Client credentials of Authorization code) bepaalt **hoe** je aan een token komt, bijvoorbeeld of er een gebruiker inlogt. De client authenticatie (client secret of private key JWT) bepaalt **hoe je aantoont welke applicatie** je bent. Bij een confidential client combineer je altijd één flow met één client authenticatiemethode.
+**Mijn tokenaanvraag geeft `invalid_client`. Wat nu?**
+Controleer of de `client_id` klopt en of het client secret niet is verlopen of ingetrokken (Profit 9). Controleer bij private key JWT of het certificaat of de `jwks_uri` correct is geconfigureerd, of `kid` de bedoelde sleutel aanwijst en of de claims `iss`, `sub`, `aud` en `exp` kloppen.
### Lees verder