From 2730c03f76abcc7d5f565c4be5a6ab487ccaef38 Mon Sep 17 00:00:00 2001 From: Tom Klute Date: Wed, 23 Sep 2026 15:22:23 +0200 Subject: [PATCH 01/18] Update OAuth docs - Refactor code structure for improved readability and maintainability --- markdownpages/profit/en/authentication.md | 727 +++++++++++++++---- markdownpages/profit/nl/authentication.md | 837 ++++++++++++++++++---- 2 files changed, 1284 insertions(+), 280 deletions(-) diff --git a/markdownpages/profit/en/authentication.md b/markdownpages/profit/en/authentication.md index 921ea65..ef9e075 100644 --- a/markdownpages/profit/en/authentication.md +++ b/markdownpages/profit/en/authentication.md @@ -1,100 +1,307 @@ --- -author: CLN -date: 2026-09-21 +author: TOKL +date: 2026-09-23 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 open OAuth 2.1 standard. | **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 **15 February 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 15 February 2027. | +| 15 February 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. | -### Format and conversion +### Migrating to OAuth (Profit 9) -A classic token as generated in AFAS Profit looks like this: +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. -``` xml +- **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. + +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== +``` + +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. + +--- + +## OAuth: overview + +| Client type | Flow | Client authentication | Token protection | Available from | +|---|---|---|---|---| +| Confidential | Client credentials | Client secret | Bearer | Current version | +| Confidential | Client credentials | Private key JWT | Bearer | **Profit 9** | +| Confidential | Authorization code + PKCE | Client secret | Bearer | Current version | +| Confidential | Authorization code + PKCE | Private key JWT | Bearer | **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?** -``` xml -PHRva2VuPjx2ZXJzaW9uPjE8L3ZlcnNpb24+PGRhdGE+QURFMzcwQkU4REFGNDBEMEExN0ZGQjkxNEU0MjY3NUU5OTk4QzJENTQ2QTJGNEZBM0U0RjNBQkZBODY3Qjk2RjwvZGF0YT48L3Rva2VuPg== +### Confidential client + +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). + +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) | Only your **public** key | +| 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= ``` +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) + +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**. + +- 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:** + +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. + +> **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. -### Applying the token +### Method 2: private key JWT (Profit 9) -Use the token in the HTTP request header with an `AfasToken` prefix. Use the `Authorization` header with the token value: +With private key JWT, you use an **asymmetric key pair**: -``` xml -AfasToken PHRva2VuPjx2ZXJzaW9uPjE8L3ZlcnNpb24+PGRhdGE+QURFMzcwQkU4REFGNDBEMEExN0ZGQjkxNEU0MjY3NUU5OTk4QzJENTQ2QTJGNEZBM0U0RjNBQkZBODY3Qjk2RjwvZGF0YT48L3Rva2VuPg== +- the **private key** remains with you and never leaves your server; +- the **public key** is registered in the app connector in Profit. + +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. + +#### Step 1: create a key pair and register the public key + +Create a key pair, for example with OpenSSL: + +```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 ``` -**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. +Register the public key (`public_key.pem`) in the app connector in Profit. + +#### Step 2: create the client assertion -### Generating a token for a user via OTP +The JWT contains the following data: -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. +**Header** +```json +{ + "alg": "RS256", + "typ": "JWT", + "kid": "" +} +``` + +**Payload** + +| Claim | Value | +|---|---| +| `iss` | Your `client_id` | +| `sub` | Your `client_id` | +| `aud` | The URL of the token endpoint, for example `https://.rest.afas.online/ProfitRestServices/oauth/token` | +| `jti` | A unique, random value per JWT (for example, a GUID) | +| `iat` | Time of creation (Unix timestamp) | +| `exp` | Expiration time (Unix timestamp). Keep this short, for example 5 minutes after `iat` | + +```json +{ + "iss": "", + "sub": "", + "aud": "https://.rest.afas.online/ProfitRestServices/oauth/token", + "jti": "5f1d3b2a-8c4e-4b8e-9a61-2f0d7c3e9b14", + "iat": 1790000000, + "exp": 1790000300 +} +``` -### Unauthorized +Sign the JWT with your private key. -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. +#### 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= +``` -## OAuth +Create a new JWT for **every** token request. -Within the OAuth protocol we support two flow types: -1. Client credentials flow -2. Authorization code flow with PKCE +#### Rotating keys +You also rotate key pairs periodically. Procedure: -### Client credentials flow +1. Create a new key pair and register the new public key in Profit. +2. Configure your application to sign JWTs with the new private key (with the matching `kid`). +3. Remove the old public key from Profit. -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. +--- -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. +## Confidential client: Client credentials flow -#### Steps to access the API +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. -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. +``` +Application Profit + | | + |-- POST /oauth/token --------------------> | + | grant_type=client_credentials | + | + client authentication | + | | + |<-- access_token -------------------------- | + | | + |-- GET /connectors/... (Bearer token) ---> | +``` -#### cURL examples +### 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,7 +310,19 @@ 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 + ```json { "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", @@ -113,113 +332,312 @@ curl -X POST https://.rest.afas.online/ProfitRestServices/oau } ``` -**API call with token:** +The access token is valid for `expires_in` seconds. In this flow, you receive no refresh token. 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 exactly match the redirect URL configured in the app connector. | +| `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. + +### 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:** + ```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 a key pair for the duration of the session. Example in the browser: + +```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` | Only if Profit requests it (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 + ```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:** + ```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": "" } ``` -**Step 3: API call with token:** +```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 may ask you to include a server-defined value (*nonce*) in the DPoP proof. In that case, you receive the error `use_dpop_nonce` together with the header `DPoP-Nonce`. In that case, create a new proof with the claim `nonce` set to that value and repeat the request. + +--- + +## 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 +645,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 +657,47 @@ 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). + +You can call only the Get- and UpdateConnector endpoints that are linked to the app connector. + +--- -### Token endpoint -These endpoints apply to both REST and SOAP. +## 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 15 February 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, whether the client secret has expired or been revoked (Profit 9), or, in the case of private key JWT, whether the public key is registered correctly and 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 +- [Profit API GetConnectoren](./get-connector) +- [Error handling](./troubleshooting) diff --git a/markdownpages/profit/nl/authentication.md b/markdownpages/profit/nl/authentication.md index 74fad03..95bfa21 100644 --- a/markdownpages/profit/nl/authentication.md +++ b/markdownpages/profit/nl/authentication.md @@ -1,99 +1,307 @@ --- -author: CLN -date: 2026-09-21 +author: TOKL +date: 2026-09-23 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 open standaard OAuth 2.1. | **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 **15 februari 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 15 februari 2027. | +| 15 februari 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. | + +### Overstappen naar OAuth (Profit 9) -### Formaat en conversie +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. -Een classic token zoals in AFAS Profit gegenereerd, ziet er als volgt uit: +- **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. -``` xml +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**: + +```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 | Huidige versie | +| Confidential | Client credentials | Private key JWT | Bearer | **Profit 9** | +| Confidential | Authorization code + PKCE | Client secret | Bearer | Huidige versie | +| Confidential | Authorization code + PKCE | Private key JWT | Bearer | **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. -``` xml -PHRva2VuPjx2ZXJzaW9uPjE8L3ZlcnNpb24+PGRhdGE+QURFMzcwQkU4REFGNDBEMEExN0ZGQjkxNEU0MjY3NUU5OTk4QzJENTQ2QTJGNEZBM0U0RjNBQkZBODY3Qjk2RjwvZGF0YT48L3Rva2VuPg== +Een public client **heeft geen client secret** en voert dus geen client authenticatie uit. In plaats daarvan zorgen drie maatregelen voor de beveiliging: + +- 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. + +> **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. + +--- + +## 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) | Alleen je **publieke** sleutel | +| 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= ``` +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 instellingen scherm van de app connector in Profit. -### Toepassen token +**Zo vernieuw je een secret zonder onderbreking:** -Het token gebruik je in de HTTP-requestheader met een AfasToken-prefix. Hiervoor gebruik je de header "Authorization" met de waarde van het token: +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. -``` xml -AfasToken PHRva2VuPjx2ZXJzaW9uPjE8L3ZlcnNpb24+PGRhdGE+QURFMzcwQkU4REFGNDBEMEExN0ZGQjkxNEU0MjY3NUU5OTk4QzJENTQ2QTJGNEZBM0U0RjNBQkZBODY3Qjk2RjwvZGF0YT48L3Rva2VuPg== +> **Tip:** plan het vernieuwen ruim vóór de vervaldatum, bijvoorbeeld als terugkerende taak in je beheerkalender. Een verlopen secret betekent dat de koppeling stopt. + +Wil je niet periodiek secrets uitwisselen? Gebruik dan [private key JWT](#methode-2-private-key-jwt-profit-9). + +### 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 key** registreer je bij de app connector in Profit. + +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. + +#### Stap 1: sleutelpaar aanmaken en publieke key registreren + +Maak een sleutelpaar aan, bijvoorbeeld met OpenSSL: + +```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 ``` -**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. +Registreer de publieke key (`public_key.pem`) bij de app connector in Profit. -### Token voor gebruiker genereren via OTP +#### Stap 2: client assertion opstellen -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. +De JWT bevat de volgende gegevens: -### Unauthorized +**Header** -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. +```json +{ + "alg": "RS256", + "typ": "JWT", + "kid": "" +} +``` +**Payload** +| Claim | Waarde | +|---|---| +| `iss` | Je `client_id` | +| `sub` | Je `client_id` | +| `aud` | De URL van het token-endpoint, bijvoorbeeld `https://.rest.afas.online/ProfitRestServices/oauth/token` | +| `jti` | Een unieke, willekeurige waarde per JWT (bijvoorbeeld een GUID) | +| `iat` | Tijdstip van aanmaken (Unix timestamp) | +| `exp` | Vervaltijdstip (Unix timestamp). Houd dit kort, bijvoorbeeld 5 minuten na `iat` | -## OAuth +```json +{ + "iss": "", + "sub": "", + "aud": "https://.rest.afas.online/ProfitRestServices/oauth/token", + "jti": "5f1d3b2a-8c4e-4b8e-9a61-2f0d7c3e9b14", + "iat": 1790000000, + "exp": 1790000300 +} +``` -Binnen het OAuth protocol ondersteunen we twee typen flows: -1. Client credentials flow -2. Authorization code flow with PKCE +Onderteken de JWT met je private key. +#### Stap 3: client assertion meesturen -### Client credential flow +In de tokenaanvraag vervang je `client_secret` door: -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. +```http +client_id= +client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer +client_assertion= +``` -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. +Maak voor elke tokenaanvraag een nieuwe JWT aan. +#### Sleutels roteren -#### Stappen voor toegang tot de API +Ook sleutelparen vervang je periodiek. Werkwijze: -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. +1. Maak een nieuw sleutelpaar aan en registreer de nieuwe publieke key in Profit. +2. Laat je applicatie JWT's ondertekenen met de nieuwe private key (met de bijbehorende `kid`). +3. Verwijder de oude publieke key uit Profit. -#### cURL voorbeelden +--- + +## 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,7 +310,19 @@ 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 + ```json { "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", @@ -112,14 +332,312 @@ curl -X POST https://.rest.afas.online/ProfitRestServices/oauth } ``` -**API aanroep met token:** +Het access token is `expires_in` seconden geldig. Bij deze flow krijg je geen refresh token. 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 exact overeenkomen met de redirect URL die bij de app connector is ingesteld. | +| `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. + +### 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 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=" +``` + +**Antwoord:** + +```json +{ + "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", + "refresh_token": "", + "token_type": "Bearer", + "expires_in": 3600 +} +``` + +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 +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` | Alleen als Profit hierom vraagt (zie [DPoP nonce](#dpop-nonce)) | + +```json +{ + "jti": "e1f3c9a2-4b7d-4c1e-8f2a-9d6b3e5a7c10", + "htm": "POST", + "htu": "https://.rest.afas.online/ProfitRestServices/oauth/token", + "iat": 1790000000 +} +``` + +Onderteken de proof met de private key uit stap 1. Maak voor elke aanvraag een nieuwe proof. + +### Stap 5: code inwisselen voor een 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=" +``` + +Let op: er is **geen** `client_secret` en **geen** `client_assertion`. + +**Antwoord:** + +```json +{ + "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", + "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 kan vragen om een door de server bepaalde waarde (*nonce*) in de DPoP proof op te nemen. In dat geval ontvang je een foutmelding `use_dpop_nonce` met de header `DPoP-Nonce`. Maak dan een nieuwe proof aan met de claim `nonce` gelijk aan die waarde en herhaal de aanvraag. + +--- + +## 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, @@ -127,7 +645,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, @@ -139,86 +657,118 @@ curl -X GET "https://.rest.afas.online/ProfitRestServices/conne } ``` -### 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:** +Voor public clients (DPoP-token): zie [Stap 6](#stap-6-connector-aanroepen-met-een-dpop-token). + +Je kunt alleen de Get- en UpdateConnectoren aanroepen die aan de app connector zijn gekoppeld. + +--- + +## Welke combinatie kies ik? + +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. + +--- + +## Veelgestelde vragen + +**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. + +**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 15 februari 2027. Vanaf Profit 9 kun je hiervoor het authenticatietype van je bestaande app connector wijzigen. Zie [Overstappen naar OAuth](#overstappen-naar-oauth-profit-9). + +**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. + +**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, of het client secret niet is verlopen of ingetrokken (Profit 9), of bij private key JWT de publieke key correct is geregistreerd en de claims `iss`, `sub`, `aud` en `exp` kloppen. + +### Lees verder + +- [Profit API GetConnectoren](./get-connector) +- [Error handling](./troubleshooting) + + +```json +{ + "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", + "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 -# 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 GET "https://.rest.afas.online/ProfitRestServices/connectors/Profit_Address?skip=0&take=100" \ + -H "Accept: application/json" \ + -H "Authorization: DPoP eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." \ + -H "DPoP: " ``` -**Stap 2: Token ophalen met autorisatiecode:** +### 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" \ - -d "grant_type=authorization_code" \ - -d "code=" \ - -d "redirect_uri=" \ - -d "client_id=" \ - -d "client_secret=" \ - -d "code_verifier=" + -H "DPoP: " \ + -d "grant_type=refresh_token" \ + -d "refresh_token=" \ + -d "client_id=" ``` -**Response voorbeeld:** -```json -{ - "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", - "token_type": "Bearer", - "expires_in": 3600, - "refresh_token": "50c90d85-a7aa-4e8a-a9b8-..." -} -``` +### DPoP nonce + +Profit kan vragen om een door de server bepaalde waarde (*nonce*) in de DPoP proof op te nemen. In dat geval krijg je een foutmelding `use_dpop_nonce` met de header `DPoP-Nonce`. Maak dan een nieuwe proof aan met de claim `nonce` gelijk aan die waarde en herhaal de aanvraag. `` + +--- + +## Een connector aanroepen met het access token + +Voor confidential clients (Bearer-token): -**Stap 3: API aanroep met 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 +776,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,28 +788,45 @@ 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). +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. +--- + +## Welke combinatie kies ik? -**Productie**: https://``.rest.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. + +--- -**Accept**: https://``.restaccept.afas.online/ProfitRestServices/oauth/token +## Veelgestelde vragen -**Test**: https://``.resttest.afas.online/ProfitRestServices/oauth/token +**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. -### Authorization endpoint -Deze endpoints gelden zowel voor REST als voor SOAP. +**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 15 februari 2027. Vanaf Profit 9 kun je hiervoor het authenticatietype van je bestaande app connector wijzigen, zie [Overstappen naar OAuth](#overstappen-naar-oauth-profit-9). -**Productie**: https://``.rest.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. -**Accept**: https://``.restaccept.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. -**Test**: https://``.resttest.afas.online/ProfitRestServices/oauth/authorize +**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, of de client secret niet is verlopen of ingetrokken (Profit 9), of bij private key JWT de publieke key correct is geregistreerd en de claims `iss`, `sub`, `aud` en `exp` kloppen. ### Lees verder From b7b78664a44a3cce4f65b88c3c3b7811fef1159e Mon Sep 17 00:00:00 2001 From: Tom Klute Date: Wed, 23 Sep 2026 15:28:58 +0200 Subject: [PATCH 02/18] Remove outdated DPoP token usage instructions and related examples from authentication documentation --- markdownpages/profit/nl/authentication.md | 132 ---------------------- 1 file changed, 132 deletions(-) diff --git a/markdownpages/profit/nl/authentication.md b/markdownpages/profit/nl/authentication.md index 95bfa21..e50b8c3 100644 --- a/markdownpages/profit/nl/authentication.md +++ b/markdownpages/profit/nl/authentication.md @@ -697,138 +697,6 @@ De flow (Client credentials of Authorization code) bepaalt **hoe** je aan een to **Mijn tokenaanvraag geeft `invalid_client`. Wat nu?** Controleer of de `client_id` klopt, of het client secret niet is verlopen of ingetrokken (Profit 9), of bij private key JWT de publieke key correct is geregistreerd en de claims `iss`, `sub`, `aud` en `exp` kloppen. -### Lees verder - -- [Profit API GetConnectoren](./get-connector) -- [Error handling](./troubleshooting) - - -```json -{ - "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", - "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 kan vragen om een door de server bepaalde waarde (*nonce*) in de DPoP proof op te nemen. In dat geval krijg je een foutmelding `use_dpop_nonce` met de header `DPoP-Nonce`. Maak dan een nieuwe proof aan met de claim `nonce` gelijk aan die waarde en herhaal de aanvraag. `` - ---- - -## 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..." -``` - -```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" - } - ] -} -``` - -Voor public clients (DPoP-token): zie [Stap 6](#stap-6-connector-aanroepen-met-een-dpop-token). - -Je kunt alleen de Get- en UpdateConnectoren aanroepen die aan de app connector zijn gekoppeld. - ---- - -## Welke combinatie kies ik? - -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. - ---- - -## Veelgestelde vragen - -**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. - -**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 15 februari 2027. Vanaf Profit 9 kun je hiervoor het authenticatietype van je bestaande app connector wijzigen, zie [Overstappen naar OAuth](#overstappen-naar-oauth-profit-9). - -**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. - -**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, of de client secret niet is verlopen of ingetrokken (Profit 9), of bij private key JWT de publieke key correct is geregistreerd en de claims `iss`, `sub`, `aud` en `exp` kloppen. - - ### Lees verder - [Profit API GetConnectoren](./get-connector) From 5ba4b81e1f57da44d519f59586ab055defb212e5 Mon Sep 17 00:00:00 2001 From: Tom Klute Date: Thu, 24 Sep 2026 11:58:05 +0200 Subject: [PATCH 03/18] Update authentication documentation to include optional DPoP token usage and clarify key management procedures --- markdownpages/profit/en/authentication.md | 95 ++++++++++++++++++----- markdownpages/profit/nl/authentication.md | 95 ++++++++++++++++++----- 2 files changed, 150 insertions(+), 40 deletions(-) diff --git a/markdownpages/profit/en/authentication.md b/markdownpages/profit/en/authentication.md index ef9e075..7f2d2bb 100644 --- a/markdownpages/profit/en/authentication.md +++ b/markdownpages/profit/en/authentication.md @@ -103,10 +103,10 @@ Handle a classic token with care: it grants access to sensitive data. After it i | Client type | Flow | Client authentication | Token protection | Available from | |---|---|---|---|---| -| Confidential | Client credentials | Client secret | Bearer | Current version | -| Confidential | Client credentials | Private key JWT | Bearer | **Profit 9** | -| Confidential | Authorization code + PKCE | Client secret | Bearer | Current version | -| Confidential | Authorization code + PKCE | Private key JWT | Bearer | **Profit 9** | +| 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). @@ -167,7 +167,7 @@ A confidential client proves, for each request to the token endpoint, who it is. | | Client secret | Private key JWT (Profit 9) | |---|---|---| -| What do you share with Profit? | A shared secret (the secret) | Only your **public** key | +| 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 | @@ -208,21 +208,29 @@ Do you not want to exchange secrets periodically? Use [private key JWT](#method- With private key JWT, you use an **asymmetric key pair**: - the **private key** remains with you and never leaves your server; -- the **public key** is registered in the app connector in Profit. +- 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. -#### Step 1: create a key pair and register the public key +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. -Create a key pair, for example with OpenSSL: +#### 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" ``` -Register the public key (`public_key.pem`) in the app connector in Profit. +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: + +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. + +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. #### Step 2: create the client assertion @@ -238,16 +246,22 @@ The JWT contains the following data: } ``` +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` | The URL of the token endpoint, for example `https://.rest.afas.online/ProfitRestServices/oauth/token` | -| `jti` | A unique, random value per JWT (for example, a GUID) | +| `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). Keep this short, for example 5 minutes after `iat` | +| `exp` | Expiration time (Unix timestamp). Prefer 60 seconds after `iat`. The maximum is 5 minutes, with 60 seconds of permitted clock skew. | ```json { @@ -274,13 +288,17 @@ client_assertion= Create a new JWT for **every** token request. +Send exactly one client authentication method. A request containing both `client_secret` and `client_assertion` is rejected. + +Failed client authentication returns HTTP status `400` with `error: invalid_client`. From Profit 9, this also applies to an incorrect client secret. + #### Rotating keys You also rotate key pairs periodically. Procedure: -1. Create a new key pair and register the new public key in Profit. +1. Create a new key pair and register the new certificate or add the new key to your 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. Remove the old public key from Profit. +3. Verify that the new key works, then remove the old certificate or JWK. This allows rotation without interruption. --- @@ -328,7 +346,7 @@ curl -X POST https://.rest.afas.online/ProfitRestServices/oau "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", "refresh_token": null, "token_type": "Bearer", - "expires_in": 3600 + "expires_in": "3600" } ``` @@ -439,7 +457,7 @@ curl -X POST https://.rest.afas.online/ProfitRestServices/oau "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", "refresh_token": "", "token_type": "Bearer", - "expires_in": 3600 + "expires_in": "3600" } ``` @@ -542,7 +560,7 @@ The header contains the **public** key of the application (`jwk`). | `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` | Only if Profit requests it (see [DPoP nonce](#dpop-nonce)) | +| `nonce` | The most recently received value from the `DPoP-Nonce` header (see [DPoP nonce](#dpop-nonce)) | ```json { @@ -557,6 +575,8 @@ Sign the proof with the private key from step 1. Create a new proof for **every* ### Step 5: exchange the code for a token +For this request, first complete the mandatory nonce exchange described under [DPoP nonce](#dpop-nonce). The repeated request then returns the token response. + ```bash curl -X POST https://.rest.afas.online/ProfitRestServices/oauth/token \ -H "Content-Type: application/x-www-form-urlencoded" \ @@ -577,7 +597,7 @@ Note: there is **no** `client_secret` and **no** `client_assertion`. "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", "refresh_token": "", "token_type": "DPoP", - "expires_in": 3600 + "expires_in": "3600" } ``` @@ -624,7 +644,42 @@ curl -X POST https://.rest.afas.online/ProfitRestServices/oau ### DPoP nonce -Profit may ask you to include a server-defined value (*nonce*) in the DPoP proof. In that case, you receive the error `use_dpop_nonce` together with the header `DPoP-Nonce`. In that case, create a new proof with the claim `nonce` set to that value and repeat the request. +Profit always requires a server-defined value (*nonce*) in the DPoP proof. Send the first request without a nonce. Profit responds with `use_dpop_nonce` and the `DPoP-Nonce` header. This is a standard step in the flow, not an exceptional condition. + +```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": "" +} +``` + +```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. --- @@ -695,7 +750,7 @@ PKCE protects the **authorization code**: only the application that started the 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. **My token request returns `invalid_client`. What now?** -Check whether the `client_id` is correct, whether the client secret has expired or been revoked (Profit 9), or, in the case of private key JWT, whether the public key is registered correctly and the claims `iss`, `sub`, `aud`, and `exp` are correct. +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 diff --git a/markdownpages/profit/nl/authentication.md b/markdownpages/profit/nl/authentication.md index e50b8c3..538ed8a 100644 --- a/markdownpages/profit/nl/authentication.md +++ b/markdownpages/profit/nl/authentication.md @@ -103,10 +103,10 @@ Behandel een classic token met zorg: het geeft toegang tot gevoelige gegevens. N | Client type | Flow | Client authenticatie | Tokenbeveiliging | Beschikbaar vanaf | |---|---|---|---|---| -| Confidential | Client credentials | Client secret | Bearer | Huidige versie | -| Confidential | Client credentials | Private key JWT | Bearer | **Profit 9** | -| Confidential | Authorization code + PKCE | Client secret | Bearer | Huidige versie | -| Confidential | Authorization code + PKCE | Private key JWT | Bearer | **Profit 9** | +| 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). @@ -167,7 +167,7 @@ Een confidential client bewijst bij elke aanvraag op het token-endpoint wie hij | | Client secret | Private key JWT (Profit 9) | |---|---|---| -| Wat deel je met Profit? | Een gedeeld geheim (de secret) | Alleen je **publieke** sleutel | +| 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 | @@ -208,21 +208,29 @@ Wil je niet periodiek secrets uitwisselen? Gebruik dan [private key JWT](#method Bij private key JWT gebruik je een **asymmetrisch sleutelpaar**: - de **private key** blijft altijd bij jou en verlaat je server nooit; -- de **publieke key** registreer je bij de app connector in Profit. +- de publieke sleutel bied je aan via een X.509-certificaat in Profit of via een JWK Set op een eigen URL. 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. -#### Stap 1: sleutelpaar aanmaken en publieke key registreren +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. -Maak een sleutelpaar aan, bijvoorbeeld met OpenSSL: +#### Stap 1: sleutelpaar aanmaken en publieke sleutel aanbieden + +Maak bijvoorbeeld met OpenSSL een RSA-sleutelpaar en een X.509-certificaat aan: ```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" ``` -Registreer de publieke key (`public_key.pem`) bij de app connector in Profit. +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. #### Stap 2: client assertion opstellen @@ -238,16 +246,22 @@ De JWT bevat de volgende gegevens: } ``` +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. + +> **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. + **Payload** | Claim | Waarde | |---|---| | `iss` | Je `client_id` | | `sub` | Je `client_id` | -| `aud` | De URL van het token-endpoint, bijvoorbeeld `https://.rest.afas.online/ProfitRestServices/oauth/token` | -| `jti` | Een unieke, willekeurige waarde per JWT (bijvoorbeeld een GUID) | +| `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). Houd dit kort, bijvoorbeeld 5 minuten na `iat` | +| `exp` | Vervaltijdstip (Unix timestamp). Gebruik bij voorkeur 60 seconden na `iat`. Het maximum is 5 minuten, met 60 seconden toegestane klokafwijking. | ```json { @@ -274,13 +288,17 @@ client_assertion= Maak voor elke tokenaanvraag een nieuwe JWT aan. +Stuur precies één client-authenticatiemethode mee. Een aanvraag met zowel `client_secret` als `client_assertion` wordt afgewezen. + +Mislukte client-authenticatie geeft HTTP-status `400` met `error: invalid_client`. Vanaf Profit 9 geldt dit ook voor een onjuist client secret. + #### Sleutels roteren Ook sleutelparen vervang je periodiek. Werkwijze: -1. Maak een nieuw sleutelpaar aan en registreer de nieuwe publieke key in Profit. +1. Maak een nieuw sleutelpaar aan en registreer het nieuwe certificaat of voeg de nieuwe sleutel aan je JWK Set toe. Meerdere certificaten kunnen tegelijk actief zijn. 2. Laat je applicatie JWT's ondertekenen met de nieuwe private key (met de bijbehorende `kid`). -3. Verwijder de oude publieke key uit Profit. +3. Controleer dat de nieuwe sleutel werkt en verwijder daarna het oude certificaat of de oude JWK. Zo roteer je zonder onderbreking. --- @@ -328,7 +346,7 @@ curl -X POST https://.rest.afas.online/ProfitRestServices/oauth "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", "refresh_token": null, "token_type": "Bearer", - "expires_in": 3600 + "expires_in": "3600" } ``` @@ -439,7 +457,7 @@ curl -X POST https://.rest.afas.online/ProfitRestServices/oauth "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", "refresh_token": "", "token_type": "Bearer", - "expires_in": 3600 + "expires_in": "3600" } ``` @@ -542,7 +560,7 @@ De header bevat de **publieke** key van de applicatie (`jwk`). | `htm` | De HTTP-methode van de aanvraag, bijvoorbeeld `POST` | | `htu` | De URL van de aanvraag, zonder querystring | | `iat` | Tijdstip van aanmaken (Unix timestamp) | -| `nonce` | Alleen als Profit hierom vraagt (zie [DPoP nonce](#dpop-nonce)) | +| `nonce` | De laatst ontvangen waarde uit de header `DPoP-Nonce` (zie [DPoP nonce](#dpop-nonce)) | ```json { @@ -557,6 +575,8 @@ Onderteken de proof met de private key uit stap 1. Maak voor elke aanvraag een n ### Stap 5: code inwisselen voor een token +Voer bij deze aanvraag eerst de verplichte nonce-uitwisseling uit zoals beschreven bij [DPoP nonce](#dpop-nonce). Daarna levert de herhaalde aanvraag het tokenantwoord op. + ```bash curl -X POST https://.rest.afas.online/ProfitRestServices/oauth/token \ -H "Content-Type: application/x-www-form-urlencoded" \ @@ -577,7 +597,7 @@ Let op: er is **geen** `client_secret` en **geen** `client_assertion`. "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", "refresh_token": "", "token_type": "DPoP", - "expires_in": 3600 + "expires_in": "3600" } ``` @@ -624,7 +644,42 @@ curl -X POST https://.rest.afas.online/ProfitRestServices/oauth ### DPoP nonce -Profit kan vragen om een door de server bepaalde waarde (*nonce*) in de DPoP proof op te nemen. In dat geval ontvang je een foutmelding `use_dpop_nonce` met de header `DPoP-Nonce`. Maak dan een nieuwe proof aan met de claim `nonce` gelijk aan die waarde en herhaal de aanvraag. +Profit vereist altijd een door de server bepaalde waarde (*nonce*) in de DPoP proof. De eerste aanvraag doe je zonder nonce. Profit antwoordt daarop met `use_dpop_nonce` en de header `DPoP-Nonce`. Dit is een vaste stap in de flow, geen uitzonderingssituatie. + +```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": "" +} +``` + +```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. --- @@ -695,7 +750,7 @@ PKCE beschermt de **authorization code**: alleen de applicatie die de inlog star 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, of het client secret niet is verlopen of ingetrokken (Profit 9), of bij private key JWT de publieke key correct is geregistreerd en de claims `iss`, `sub`, `aud` en `exp` kloppen. +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 From 8ad3ae5b97039600c598433eed88e0e6f94ec9bb Mon Sep 17 00:00:00 2001 From: Tom Klute Date: Mon, 28 Sep 2026 11:44:25 +0200 Subject: [PATCH 04/18] Update authentication documentation for Profit 9: add client_secret_basic method, clarify error handling, and refine response details --- markdownpages/profit/en/authentication.md | 35 ++++++++++++++++------- markdownpages/profit/nl/authentication.md | 35 ++++++++++++++++------- 2 files changed, 48 insertions(+), 22 deletions(-) diff --git a/markdownpages/profit/en/authentication.md b/markdownpages/profit/en/authentication.md index 7f2d2bb..dbe4a11 100644 --- a/markdownpages/profit/en/authentication.md +++ b/markdownpages/profit/en/authentication.md @@ -183,6 +183,8 @@ client_id= client_secret= ``` +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) @@ -288,9 +290,9 @@ client_assertion= Create a new JWT for **every** token request. -Send exactly one client authentication method. A request containing both `client_secret` and `client_assertion` is rejected. +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. -Failed client authentication returns HTTP status `400` with `error: invalid_client`. From Profit 9, this also applies to an incorrect client secret. +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 @@ -300,6 +302,10 @@ You also rotate key pairs periodically. Procedure: 2. Configure your application to sign JWTs with the new private key (with the matching `kid`). 3. Verify that the new key works, then remove the old certificate or JWK. This allows rotation without interruption. +### 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 @@ -339,18 +345,17 @@ curl -X POST https://.rest.afas.online/ProfitRestServices/oau -d "client_assertion=" ``` -### Response +### Response (Profit 9) ```json { "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", - "refresh_token": null, "token_type": "Bearer", - "expires_in": "3600" + "expires_in": 3600 } ``` -The access token is valid for `expires_in` seconds. In this flow, you receive no refresh token. 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. +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. --- @@ -404,7 +409,7 @@ https://.rest.afas.online/ProfitRestServices/oauth/authorize |---|---| | `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 exactly match the redirect URL configured in 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` | @@ -419,6 +424,10 @@ After logging in, Profit redirects the user back to your `redirect_uri`: 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. @@ -450,14 +459,14 @@ curl -X POST https://.rest.afas.online/ProfitRestServices/oau -d "client_assertion=" ``` -**Response:** +**Response (Profit 9):** ```json { "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", "refresh_token": "", "token_type": "Bearer", - "expires_in": "3600" + "expires_in": 3600 } ``` @@ -590,14 +599,14 @@ curl -X POST https://.rest.afas.online/ProfitRestServices/oau Note: there is **no** `client_secret` and **no** `client_assertion`. -**Response:** +**Response (Profit 9):** ```json { "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", "refresh_token": "", "token_type": "DPoP", - "expires_in": "3600" + "expires_in": 3600 } ``` @@ -646,6 +655,8 @@ curl -X POST https://.rest.afas.online/ProfitRestServices/oau Profit always requires a server-defined value (*nonce*) in the DPoP proof. Send the first request without a nonce. Profit responds with `use_dpop_nonce` and the `DPoP-Nonce` header. This is a standard step in the flow, not an exceptional condition. +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: @@ -714,6 +725,8 @@ curl -X GET "https://.rest.afas.online/ProfitRestServices/con 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. Without credentials, both `Bearer` and `DPoP` are offered, without an error code. If a token is rejected, the challenge for the scheme used includes `error="invalid_token"`, for example `WWW-Authenticate: Bearer error="invalid_token"` or `WWW-Authenticate: DPoP error="invalid_token"`. A DPoP proof signed with a different key than the one bound to the token also returns `invalid_token` ([RFC 9449 §7.1](https://www.rfc-editor.org/rfc/rfc9449#section-7.1)). A failure of the replay-detection store returns HTTP `503` instead of `401`; treat this as a temporary server failure, not an invalid token. + You can call only the Get- and UpdateConnector endpoints that are linked to the app connector. --- diff --git a/markdownpages/profit/nl/authentication.md b/markdownpages/profit/nl/authentication.md index 538ed8a..15c5e29 100644 --- a/markdownpages/profit/nl/authentication.md +++ b/markdownpages/profit/nl/authentication.md @@ -183,6 +183,8 @@ client_id= client_secret= ``` +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) @@ -288,9 +290,9 @@ client_assertion= Maak voor elke tokenaanvraag een nieuwe JWT aan. -Stuur precies één client-authenticatiemethode mee. Een aanvraag met zowel `client_secret` als `client_assertion` wordt afgewezen. +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. -Mislukte client-authenticatie geeft HTTP-status `400` met `error: invalid_client`. Vanaf Profit 9 geldt dit ook voor een onjuist client secret. +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 @@ -300,6 +302,10 @@ Ook sleutelparen vervang je periodiek. Werkwijze: 2. Laat je applicatie JWT's ondertekenen met de nieuwe private key (met de bijbehorende `kid`). 3. Controleer dat de nieuwe sleutel werkt en verwijder daarna het oude certificaat of de oude JWK. Zo roteer je zonder onderbreking. +### 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 @@ -339,18 +345,17 @@ curl -X POST https://.rest.afas.online/ProfitRestServices/oauth -d "client_assertion=" ``` -### Antwoord +### Antwoord (Profit 9) ```json { "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", - "refresh_token": null, "token_type": "Bearer", - "expires_in": "3600" + "expires_in": 3600 } ``` -Het access token is `expires_in` seconden geldig. Bij deze flow krijg je geen refresh token. 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. +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. --- @@ -404,7 +409,7 @@ https://.rest.afas.online/ProfitRestServices/oauth/authorize |---|---| | `response_type` | Altijd `code` | | `client_id` | De client ID van de app connector | -| `redirect_uri` | De URL waarnaar Profit de gebruiker terugstuurt. Deze moet exact overeenkomen met de redirect URL die bij de app connector is ingesteld. | +| `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` | @@ -419,6 +424,10 @@ Na het inloggen stuurt Profit de gebruiker terug naar je `redirect_uri`: 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. @@ -450,14 +459,14 @@ curl -X POST https://.rest.afas.online/ProfitRestServices/oauth -d "client_assertion=" ``` -**Antwoord:** +**Antwoord (Profit 9):** ```json { "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", "refresh_token": "", "token_type": "Bearer", - "expires_in": "3600" + "expires_in": 3600 } ``` @@ -590,14 +599,14 @@ curl -X POST https://.rest.afas.online/ProfitRestServices/oauth Let op: er is **geen** `client_secret` en **geen** `client_assertion`. -**Antwoord:** +**Antwoord (Profit 9):** ```json { "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", "refresh_token": "", "token_type": "DPoP", - "expires_in": "3600" + "expires_in": 3600 } ``` @@ -646,6 +655,8 @@ curl -X POST https://.rest.afas.online/ProfitRestServices/oauth Profit vereist altijd een door de server bepaalde waarde (*nonce*) in de DPoP proof. De eerste aanvraag doe je zonder nonce. Profit antwoordt daarop met `use_dpop_nonce` en de header `DPoP-Nonce`. Dit is een vaste stap in de flow, geen uitzonderingssituatie. +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: @@ -714,6 +725,8 @@ curl -X GET "https://.rest.afas.online/ProfitRestServices/conne Voor public clients (DPoP-token): zie [Stap 6](#stap-6-connector-aanroepen-met-een-dpop-token). +Vanaf **Profit 9** bevat iedere HTTP `401` van de resource server een `WWW-Authenticate`-header. Zonder credentials worden zowel `Bearer` als `DPoP` aangeboden, zonder foutcode. Wordt een token afgewezen, dan vermeldt de challenge van het gebruikte schema `error="invalid_token"`, bijvoorbeeld `WWW-Authenticate: Bearer error="invalid_token"` of `WWW-Authenticate: DPoP error="invalid_token"`. Ook een DPoP proof met een andere sleutel dan die waaraan het token is gebonden geeft `invalid_token` ([RFC 9449 §7.1](https://www.rfc-editor.org/rfc/rfc9449#section-7.1)). Een storing van de opslag voor replay-detectie geeft HTTP `503` in plaats van `401`; behandel dit als een tijdelijke serverstoring, niet als een ongeldig token. + Je kunt alleen de Get- en UpdateConnectoren aanroepen die aan de app connector zijn gekoppeld. --- From 85a25b8b9e75779defe03b16b4b8bcf0af796040 Mon Sep 17 00:00:00 2001 From: Tom Klute Date: Mon, 28 Sep 2026 14:10:03 +0200 Subject: [PATCH 05/18] Clarify DPoP nonce usage in authentication documentation: specify valid nonce requirement and update request instructions --- markdownpages/profit/en/authentication.md | 4 ++-- markdownpages/profit/nl/authentication.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/markdownpages/profit/en/authentication.md b/markdownpages/profit/en/authentication.md index dbe4a11..5220752 100644 --- a/markdownpages/profit/en/authentication.md +++ b/markdownpages/profit/en/authentication.md @@ -584,7 +584,7 @@ Sign the proof with the private key from step 1. Create a new proof for **every* ### Step 5: exchange the code for a token -For this request, first complete the mandatory nonce exchange described under [DPoP nonce](#dpop-nonce). The repeated request then returns the token response. +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 \ @@ -653,7 +653,7 @@ curl -X POST https://.rest.afas.online/ProfitRestServices/oau ### DPoP nonce -Profit always requires a server-defined value (*nonce*) in the DPoP proof. Send the first request without a nonce. Profit responds with `use_dpop_nonce` and the `DPoP-Nonce` header. This is a standard step in the flow, not an exceptional condition. +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. diff --git a/markdownpages/profit/nl/authentication.md b/markdownpages/profit/nl/authentication.md index 15c5e29..f207b25 100644 --- a/markdownpages/profit/nl/authentication.md +++ b/markdownpages/profit/nl/authentication.md @@ -584,7 +584,7 @@ Onderteken de proof met de private key uit stap 1. Maak voor elke aanvraag een n ### Stap 5: code inwisselen voor een token -Voer bij deze aanvraag eerst de verplichte nonce-uitwisseling uit zoals beschreven bij [DPoP nonce](#dpop-nonce). Daarna levert de herhaalde aanvraag het tokenantwoord op. +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 \ @@ -653,7 +653,7 @@ curl -X POST https://.rest.afas.online/ProfitRestServices/oauth ### DPoP nonce -Profit vereist altijd een door de server bepaalde waarde (*nonce*) in de DPoP proof. De eerste aanvraag doe je zonder nonce. Profit antwoordt daarop met `use_dpop_nonce` en de header `DPoP-Nonce`. Dit is een vaste stap in de flow, geen uitzonderingssituatie. +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. From 23680973a70337694417ca5a409cd5f81c1ad4bc Mon Sep 17 00:00:00 2001 From: Tom Klute Date: Mon, 28 Sep 2026 14:13:40 +0200 Subject: [PATCH 06/18] Clarify HTTP 401 response handling in authentication documentation: specify conditions for Bearer and DPoP token rejection, and update server error handling details. --- markdownpages/profit/en/authentication.md | 8 +++++++- markdownpages/profit/nl/authentication.md | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/markdownpages/profit/en/authentication.md b/markdownpages/profit/en/authentication.md index 5220752..4b9e921 100644 --- a/markdownpages/profit/en/authentication.md +++ b/markdownpages/profit/en/authentication.md @@ -725,7 +725,13 @@ curl -X GET "https://.rest.afas.online/ProfitRestServices/con 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. Without credentials, both `Bearer` and `DPoP` are offered, without an error code. If a token is rejected, the challenge for the scheme used includes `error="invalid_token"`, for example `WWW-Authenticate: Bearer error="invalid_token"` or `WWW-Authenticate: DPoP error="invalid_token"`. A DPoP proof signed with a different key than the one bound to the token also returns `invalid_token` ([RFC 9449 §7.1](https://www.rfc-editor.org/rfc/rfc9449#section-7.1)). A failure of the replay-detection store returns HTTP `503` instead of `401`; treat this as a temporary server failure, not an invalid 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. + +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. diff --git a/markdownpages/profit/nl/authentication.md b/markdownpages/profit/nl/authentication.md index f207b25..f52faf1 100644 --- a/markdownpages/profit/nl/authentication.md +++ b/markdownpages/profit/nl/authentication.md @@ -725,7 +725,13 @@ curl -X GET "https://.rest.afas.online/ProfitRestServices/conne Voor public clients (DPoP-token): zie [Stap 6](#stap-6-connector-aanroepen-met-een-dpop-token). -Vanaf **Profit 9** bevat iedere HTTP `401` van de resource server een `WWW-Authenticate`-header. Zonder credentials worden zowel `Bearer` als `DPoP` aangeboden, zonder foutcode. Wordt een token afgewezen, dan vermeldt de challenge van het gebruikte schema `error="invalid_token"`, bijvoorbeeld `WWW-Authenticate: Bearer error="invalid_token"` of `WWW-Authenticate: DPoP error="invalid_token"`. Ook een DPoP proof met een andere sleutel dan die waaraan het token is gebonden geeft `invalid_token` ([RFC 9449 §7.1](https://www.rfc-editor.org/rfc/rfc9449#section-7.1)). Een storing van de opslag voor replay-detectie geeft HTTP `503` in plaats van `401`; behandel dit als een tijdelijke serverstoring, niet als een ongeldig 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. From 9c309c761cf62de707a363fdd9d912d91599e606 Mon Sep 17 00:00:00 2001 From: Tom Klute Date: Tue, 29 Sep 2026 13:20:56 +0200 Subject: [PATCH 07/18] Add best practices for managing multiple customer environments in authentication documentation --- markdownpages/profit/en/authentication.md | 12 +++++++++++- markdownpages/profit/nl/authentication.md | 12 +++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/markdownpages/profit/en/authentication.md b/markdownpages/profit/en/authentication.md index 4b9e921..c4cf8a5 100644 --- a/markdownpages/profit/en/authentication.md +++ b/markdownpages/profit/en/authentication.md @@ -234,6 +234,14 @@ The private key (`private_key.pem`) remains with you and is never sent to Profit 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. +#### 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: @@ -298,10 +306,12 @@ From Profit 9, an unknown client or failed client authentication returns `invali You also rotate key pairs periodically. Procedure: -1. Create a new key pair and register the new certificate or add the new key to your JWK Set. Multiple certificates can be active at the same time. +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, then remove the old certificate or JWK. This allows rotation without interruption. +During the transition, two keys are temporarily active for that customer environment. If you suspect that a private key has leaked, revoke the corresponding public key in Profit or remove it from the JWK Set, then replace the key immediately. 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. diff --git a/markdownpages/profit/nl/authentication.md b/markdownpages/profit/nl/authentication.md index f52faf1..b6ef814 100644 --- a/markdownpages/profit/nl/authentication.md +++ b/markdownpages/profit/nl/authentication.md @@ -234,6 +234,14 @@ De private key (`private_key.pem`) blijft bij jou en gaat nooit naar Profit. Je 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: @@ -298,10 +306,12 @@ Vanaf Profit 9 geeft een onbekende client of mislukte client-authenticatie `inva Ook sleutelparen vervang je periodiek. Werkwijze: -1. Maak een nieuw sleutelpaar aan en registreer het nieuwe certificaat of voeg de nieuwe sleutel aan je JWK Set toe. Meerdere certificaten kunnen tegelijk actief zijn. +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 en verwijder daarna het oude certificaat of de oude JWK. Zo roteer je zonder onderbreking. +Tijdens de overstap zijn er dus tijdelijk twee sleutels actief voor die klantomgeving. Vermoed je dat een private key is uitgelekt, trek dan de bijbehorende publieke sleutel in Profit of uit de JWK Set in en vervang de sleutel direct. 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`. From c6e713cb1672f2677439c174467db55ce4027831 Mon Sep 17 00:00:00 2001 From: Tom Klute Date: Tue, 29 Sep 2026 13:29:09 +0200 Subject: [PATCH 08/18] Clarify key rotation process in authentication documentation: specify overlap period for old keys and reference OpenID Connect guidance. --- markdownpages/profit/en/authentication.md | 5 +++-- markdownpages/profit/nl/authentication.md | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/markdownpages/profit/en/authentication.md b/markdownpages/profit/en/authentication.md index c4cf8a5..a3a1e66 100644 --- a/markdownpages/profit/en/authentication.md +++ b/markdownpages/profit/en/authentication.md @@ -308,9 +308,10 @@ 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, then remove the old certificate or JWK. This allows rotation without interruption. +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, two keys are temporarily active for that customer environment. If you suspect that a private key has leaked, revoke the corresponding public key in Profit or remove it from the JWK Set, then replace the key immediately. Also check that the relevant app connectors and customer environments use separate keys. +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) diff --git a/markdownpages/profit/nl/authentication.md b/markdownpages/profit/nl/authentication.md index b6ef814..be449c7 100644 --- a/markdownpages/profit/nl/authentication.md +++ b/markdownpages/profit/nl/authentication.md @@ -308,9 +308,10 @@ 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 en verwijder daarna het oude certificaat of de oude JWK. Zo roteer je zonder onderbreking. +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 zijn er dus tijdelijk twee sleutels actief voor die klantomgeving. Vermoed je dat een private key is uitgelekt, trek dan de bijbehorende publieke sleutel in Profit of uit de JWK Set in en vervang de sleutel direct. Controleer ook of de betreffende app connectoren en klantomgevingen afzonderlijke sleutels gebruiken. +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) From 047999f1757816b61927e9a2d6cc73f7f0c94e4b Mon Sep 17 00:00:00 2001 From: Tom Klute Date: Wed, 30 Sep 2026 10:02:33 +0200 Subject: [PATCH 09/18] Update expiration date for classic tokens in authentication documentation: change from 15 February 2027 to 1 May 2027. --- markdownpages/profit/en/authentication.md | 8 ++++---- markdownpages/profit/nl/authentication.md | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/markdownpages/profit/en/authentication.md b/markdownpages/profit/en/authentication.md index a3a1e66..74d7a6e 100644 --- a/markdownpages/profit/en/authentication.md +++ b/markdownpages/profit/en/authentication.md @@ -16,7 +16,7 @@ To call the Profit API, an external application must authenticate itself. Profit > ⚠️ **Classic tokens are being phased out** > -> - Existing classic tokens expire on **15 February 2027**. +> - 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. @@ -60,8 +60,8 @@ With a classic token, the integrating application uses the same fixed key for ev | Date | What changes? | |---|---| -| September 2026 | Existing classic tokens receive an expiration date of 15 February 2027. | -| 15 February 2027 | Existing classic tokens expire. Integrations that depend on them stop working. You can still create new classic tokens with a limited validity period. | +| 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) @@ -768,7 +768,7 @@ You can call only the Get- and UpdateConnector endpoints that are linked to the 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. **I still use a classic token. What should I do?** -Migrate to OAuth before 31 August 2027. Existing classic tokens already expire on 15 February 2027. From Profit 9, you can change the authentication type of your existing app connector. See [Migrating to OAuth](#migrating-to-oauth-profit-9). +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). **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. diff --git a/markdownpages/profit/nl/authentication.md b/markdownpages/profit/nl/authentication.md index be449c7..316395e 100644 --- a/markdownpages/profit/nl/authentication.md +++ b/markdownpages/profit/nl/authentication.md @@ -16,7 +16,7 @@ Om de Profit API aan te roepen, moet een externe applicatie zich authenticeren. > ⚠️ **Classic tokens worden uitgefaseerd** > -> - Bestaande classic tokens verlopen op **15 februari 2027**. +> - 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. @@ -60,8 +60,8 @@ Bij een classic token gebruikt de koppelende applicatie bij elke aanroep dezelfd | Datum | Wat verandert er? | |---|---| -| September 2026 | Bestaande classic tokens krijgen een einddatum van 15 februari 2027. | -| 15 februari 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. | +| 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. | ### Overstappen naar OAuth (Profit 9) @@ -768,7 +768,7 @@ Je kunt alleen de Get- en UpdateConnectoren aanroepen die aan de app connector z 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. **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 15 februari 2027. Vanaf Profit 9 kun je hiervoor het authenticatietype van je bestaande app connector wijzigen. Zie [Overstappen naar OAuth](#overstappen-naar-oauth-profit-9). +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). **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. From 673465848da8caa2b04dac623a2fe6bf6b9f22d8 Mon Sep 17 00:00:00 2001 From: Tom Klute Date: Wed, 30 Sep 2026 10:06:38 +0200 Subject: [PATCH 10/18] Update date in authentication documentation to 30 September 2026 --- markdownpages/profit/en/authentication.md | 2 +- markdownpages/profit/nl/authentication.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/markdownpages/profit/en/authentication.md b/markdownpages/profit/en/authentication.md index 74d7a6e..570400d 100644 --- a/markdownpages/profit/en/authentication.md +++ b/markdownpages/profit/en/authentication.md @@ -1,6 +1,6 @@ --- author: TOKL -date: 2026-09-23 +date: 2026-09-30 tags: GetConnector, AppConnector, Integration, Configuration, Authentication, Authorization title: Authentication --- diff --git a/markdownpages/profit/nl/authentication.md b/markdownpages/profit/nl/authentication.md index 316395e..c79c338 100644 --- a/markdownpages/profit/nl/authentication.md +++ b/markdownpages/profit/nl/authentication.md @@ -1,6 +1,6 @@ --- author: TOKL -date: 2026-09-23 +date: 2026-09-30 tags: GetConnector, AppConnector, Integration, Configuration, Authentication, Authorization title: Authenticatie --- From b1fca5bbf2d93916bf00d76e0b4fe20ae1ae72c2 Mon Sep 17 00:00:00 2001 From: Heimmot24 Date: Wed, 30 Sep 2026 10:16:14 +0200 Subject: [PATCH 11/18] Update authentication instructions for key pair creation Clarify instructions for creating a key pair in the browser. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- markdownpages/profit/en/authentication.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/markdownpages/profit/en/authentication.md b/markdownpages/profit/en/authentication.md index 570400d..18d5faa 100644 --- a/markdownpages/profit/en/authentication.md +++ b/markdownpages/profit/en/authentication.md @@ -525,7 +525,7 @@ The result: a stolen access token is unusable without the corresponding private ### Step 1: create a key pair -Create a key pair for the duration of the session. Example in the browser: +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( From 4cebe72318f4752da100d80bb0627cc1066c3338 Mon Sep 17 00:00:00 2001 From: Heimmot24 Date: Wed, 30 Sep 2026 10:17:25 +0200 Subject: [PATCH 12/18] Add nonce to authentication parameters Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- markdownpages/profit/en/authentication.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/markdownpages/profit/en/authentication.md b/markdownpages/profit/en/authentication.md index 18d5faa..5e1a69f 100644 --- a/markdownpages/profit/en/authentication.md +++ b/markdownpages/profit/en/authentication.md @@ -638,8 +638,8 @@ The proof for an API call contains, in addition to `jti`, `htm`, `htu`, and `iat "htm": "GET", "htu": "https://.rest.afas.online/ProfitRestServices/connectors/Profit_Address", "iat": 1790000060, - "ath": "" -} + "ath": "", + "nonce": "" ``` ```bash From 52d4af15136df2baff116afc24f62174741db346 Mon Sep 17 00:00:00 2001 From: Heimmot24 Date: Wed, 30 Sep 2026 10:19:02 +0200 Subject: [PATCH 13/18] Update authentication.md with OAuth details Clarify usage of OAuth flows for SOAP API. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- markdownpages/profit/en/authentication.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/markdownpages/profit/en/authentication.md b/markdownpages/profit/en/authentication.md index 5e1a69f..c0cc5bf 100644 --- a/markdownpages/profit/en/authentication.md +++ b/markdownpages/profit/en/authentication.md @@ -746,6 +746,8 @@ If the replay-detection store is temporarily unavailable, Profit returns HTTP `5 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? From e9fd12d3435a47e049926086f2ac97fe1ff70e9a Mon Sep 17 00:00:00 2001 From: Heimmot24 Date: Wed, 30 Sep 2026 10:21:57 +0200 Subject: [PATCH 14/18] Update authentication documentation with OAuth details Added information about OAuth flows for SOAP API and usage of access tokens. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- markdownpages/profit/nl/authentication.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/markdownpages/profit/nl/authentication.md b/markdownpages/profit/nl/authentication.md index c79c338..c6ebd44 100644 --- a/markdownpages/profit/nl/authentication.md +++ b/markdownpages/profit/nl/authentication.md @@ -746,6 +746,8 @@ Is de opslag voor replay-detectie tijdelijk niet beschikbaar, dan geeft Profit H Je kunt alleen de Get- en UpdateConnectoren aanroepen die aan de app connector zijn gekoppeld. +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. + --- ## Welke combinatie kies ik? From 79b2c5f6a1d2ad1271dad2af672e7fdde444d7ac Mon Sep 17 00:00:00 2001 From: Heimmot24 Date: Wed, 30 Sep 2026 10:22:50 +0200 Subject: [PATCH 15/18] Clarify OAuth description in authentication documentation Updated OAuth description to reflect the use of the OAuth 2.1 Internet-Draft. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- markdownpages/profit/en/authentication.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/markdownpages/profit/en/authentication.md b/markdownpages/profit/en/authentication.md index c0cc5bf..a472fc0 100644 --- a/markdownpages/profit/en/authentication.md +++ b/markdownpages/profit/en/authentication.md @@ -11,7 +11,7 @@ To call the Profit API, an external application must authenticate itself. Profit | Method | Description | Status | |---|---|---| -| **OAuth** | The application first requests a temporary *access token* and uses it for each call. Based on the open OAuth 2.1 standard. | **Recommended**, default for all new integrations | +| **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 | > ⚠️ **Classic tokens are being phased out** From 5f592a6451e049cde970cb24f1d38399b0efd5b0 Mon Sep 17 00:00:00 2001 From: Heimmot24 Date: Wed, 30 Sep 2026 10:23:12 +0200 Subject: [PATCH 16/18] Fix link formatting for GetConnectors section Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- markdownpages/profit/en/authentication.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/markdownpages/profit/en/authentication.md b/markdownpages/profit/en/authentication.md index a472fc0..73f20bf 100644 --- a/markdownpages/profit/en/authentication.md +++ b/markdownpages/profit/en/authentication.md @@ -786,5 +786,5 @@ Check whether the `client_id` is correct and whether the client secret has expir ### Read more -- [Profit API GetConnectoren](./get-connector) +- [Profit API GetConnectors](./get-connector) - [Error handling](./troubleshooting) From 1380b30f0ac02d04e8156eaf1c6607c54331bcaf Mon Sep 17 00:00:00 2001 From: Heimmot24 Date: Wed, 30 Sep 2026 10:23:36 +0200 Subject: [PATCH 17/18] Update OAuth description in authentication.md Updated the description of the OAuth method to reflect the use of the OAuth 2.1 Internet-Draft. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- markdownpages/profit/nl/authentication.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/markdownpages/profit/nl/authentication.md b/markdownpages/profit/nl/authentication.md index c6ebd44..0fc0c33 100644 --- a/markdownpages/profit/nl/authentication.md +++ b/markdownpages/profit/nl/authentication.md @@ -11,7 +11,7 @@ Om de Profit API aan te roepen, moet een externe applicatie zich authenticeren. | Methode | Omschrijving | Status | |---|---|---| -| **OAuth** | De applicatie vraagt eerst een tijdelijk *access token* aan en gebruikt dat bij elke aanroep. Gebaseerd op de open standaard OAuth 2.1. | **Aanbevolen**, standaard voor alle nieuwe koppelingen | +| **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 | > ⚠️ **Classic tokens worden uitgefaseerd** From b57249a9febe7160e18051c58184a00b652de42b Mon Sep 17 00:00:00 2001 From: Heimmot24 Date: Wed, 30 Sep 2026 10:24:13 +0200 Subject: [PATCH 18/18] Fix typo in expiration date instructions Corrected a typo in the authentication documentation regarding the expiration date of client secrets. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- markdownpages/profit/nl/authentication.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/markdownpages/profit/nl/authentication.md b/markdownpages/profit/nl/authentication.md index 0fc0c33..ad75ecb 100644 --- a/markdownpages/profit/nl/authentication.md +++ b/markdownpages/profit/nl/authentication.md @@ -192,7 +192,7 @@ Bewaar een client secret zoals je een wachtwoord bewaart: in een secret store of 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 instellingen scherm van de app connector in Profit. +- De vervaldatum vind je in het instellingenscherm van de app connector in Profit. **Zo vernieuw je een secret zonder onderbreking:**