Acting on behalf of users
With delegated OAuth, your application works for a person of the provider: the person signs in to MyCLM, authorizes your application once, and your application calls the API with a token of that person. The signature request is sent by Marie, the task is created by Marie, and the audit log records “ImmoCRM on behalf of Marie Durand”.
- Authentication and token issuance: the identity provider of the MyCLM provider (OpenID Connect, authorization code + PKCE).
- Authorization: MyCLM. The business scopes are recorded in the consents, never in the token: every call checks them.
Your partner application needs an OAuth client (enabled by MyCLM in the backoffice). You receive:
| Item | Use |
|---|---|
client_id |
OAuth client id, for the consent page and the token endpoint. |
| Key file (JSON, shown once) | { type, keyId, key, clientId }: key is the RSA private key used to sign the client assertion (private_key_jwt). MyCLM does not keep it. |
| Consent page | https://<provider app domain>/oauth/authorize |
| Issuer / token URL | https://<provider auth domain> and https://<provider auth domain>/oauth/v2/token |
| Redirect URIs | Registered on your application (HTTPS, or localhost in development), exact match. |
Who authorizes what
Section titled “Who authorizes what”- The organization approves your application (an administrator with
org.manage): the maximum scopes your application may use in that organization. Either on the connected apps consent page, or directly on the authorize page below when the administrator is the first person to connect. The administrator may also consent on behalf of all members: members then do not have to authorize the application themselves. - Each person authorizes your application once per organization (scopes ⊆ the approved scopes), unless the organization consented for all members.
A call gets the permissions of the person (their role where they are members), restricted to:
- the organizations that approved your application and where the person authorized it (or the administrator consented for all members);
- the business scopes authorized there (e.g. with
contacts:readonly, contacts are read-only and signature requests are refused, even for an owner).
Organization security policies (second factor, session duration) apply to delegated tokens exactly as in the MyCLM app.
The flow
Section titled “The flow”Your app MyCLM app (consent) Identity provider MyCLM API │ 1. /oauth/authorize?… ─►│ │ │ │ │ 2. sign-in, consent ─────► authorize (same params) │ │◄──────────────── 3. redirect_uri?code=…&state=… ───┘ │ │ 4. POST /oauth/v2/token (code, code_verifier, client assertion) ─►│ │ │◄──────────────────────────── access_token (10 min) + refresh_token │ │ 5. GET /v1/me, business calls with the access token ──────────────────────────►│-
Send the browser to the consent page of the provider app:
https://<provider app domain>/oauth/authorize?client_id=<your client_id>&redirect_uri=https://app.immocrm.fr/oauth/callback&scope=signature:write contacts:read&state=<random value bound to the user session>&code_challenge=<BASE64URL(SHA256(code_verifier))>&code_challenge_method=S256Parameter Description client_idYour OAuth client id. redirect_uriOne of the registered redirect URIs, exact match. Otherwise the page shows an error and never redirects. scopeSpace separated business scopes ( signature,registered_mail,contacts,tasks,:read/:write). OpenID scopes are ignored here. Omitted: every business scope of your application.stateReturned as is: check it on return (CSRF). code_challenge,code_challenge_methodPKCE, required, S256only.org_idOptional: preselects an organization. -
The person signs in and decides. The page shows your application and the requested scopes, and the organizations of the person: approved ones, and, for administrators, the ones they can approve. After the consent, MyCLM sends the browser to the identity provider with the same
client_id,redirect_uri,stateand PKCE challenge, and the scopesopenid offline_access urn:zitadel:iam:org:project:id:<apiProjectId>:aud(audience of the MyCLM API). The person is already signed in: the code comes back at once.Denied:
redirect_uri?error=access_denied&state=…. -
Receive the code on
redirect_uri(?code=…&state=…), checkstate. -
Exchange the code at the token endpoint of the identity provider, authenticated with a client assertion signed with your key (RFC 7523): header
{ "alg": "RS256", "kid": <keyId> }, claimsiss=sub= yourclient_id,aud= the issuer,iat,exp(≤ 1 hour).import { createPrivateKey } from 'node:crypto';import { SignJWT } from 'jose';const keyFile = JSON.parse(process.env.MLT_KEY_FILE!); // { keyId, key, clientId }const assertion = await new SignJWT({}).setProtectedHeader({ alg: 'RS256', kid: keyFile.keyId }).setIssuer(keyFile.clientId).setSubject(keyFile.clientId).setAudience(process.env.MLT_ISSUER!).setIssuedAt().setExpirationTime('5m').sign(createPrivateKey(keyFile.key));const tokens = await fetch(`${process.env.MLT_ISSUER}/oauth/v2/token`, {method: 'POST',headers: { 'Content-Type': 'application/x-www-form-urlencoded' },body: new URLSearchParams({grant_type: 'authorization_code',code,redirect_uri: 'https://app.immocrm.fr/oauth/callback',code_verifier: codeVerifier,client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',client_assertion: assertion,}),}).then((r) => r.json());// { access_token, refresh_token, id_token, expires_in: 600, token_type: 'Bearer' }import json, os, time, jwt, requests # PyJWT[crypto]key_file = json.loads(os.environ["MLT_KEY_FILE"])now = int(time.time())assertion = jwt.encode({"iss": key_file["clientId"], "sub": key_file["clientId"], "aud": os.environ["MLT_ISSUER"], "iat": now, "exp": now + 300},key_file["key"], algorithm="RS256", headers={"kid": key_file["keyId"]},)tokens = requests.post(f"{os.environ['MLT_ISSUER']}/oauth/v2/token", data={"grant_type": "authorization_code","code": code,"redirect_uri": "https://app.immocrm.fr/oauth/callback","code_verifier": code_verifier,"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer","client_assertion": assertion,}, timeout=10).json() -
Call the API with
Authorization: Bearer <access_token>. Access tokens last 10 minutes: renew them with the refresh token (grant_type=refresh_token, same client assertion). Refresh tokens rotate: keep the new one each time.
When access is missing
Section titled “When access is missing”A call without a valid consent answers 403 consent_required, with the consent page URL to send the person to (add your redirect_uri, scope, state and PKCE parameters):
{ "error": { "code": "consent_required", "message": "The user has not authorized this application (for this organization)", "details": { "authorizeUrl": "https://<provider app domain>/oauth/authorize?client_id=…", "orgId": "…" } }}This also happens when the person revoked your application (list of their authorized applications in the MyCLM app), or when an administrator revoked the approval of the organization: access stops at the next call. Refresh tokens already issued stay valid at the identity provider until they expire, but the tokens they give are refused.
- Only registered OAuth clients are accepted: a token of any other client of the identity provider answers
401 unknown_client, even with the API audience. - Delegated tokens never reach provisioning (
403 machine_credentials_required) nor the management of integrations (API keys, webhooks, grants:403 humans_only). Use the machine credentials of your application (client_credentials) for provisioning. - Rate limit: per application and person (the rate limit of your application, per minute).
- Resources you create belong to the person; the audit log records your application.