fikiri-id-sdk 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,180 @@
1
+ Metadata-Version: 2.4
2
+ Name: fikiri-id-sdk
3
+ Version: 0.1.0
4
+ Summary: SDK officiel FikiriID — intégration SSO OIDC pour plateformes Python
5
+ License-Expression: MIT
6
+ Keywords: fikiri,oidc,sso,oauth2,authentication
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: httpx>=0.27
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest; extra == "dev"
12
+ Requires-Dist: pytest-asyncio; extra == "dev"
13
+ Requires-Dist: respx; extra == "dev"
14
+ Provides-Extra: fastapi
15
+ Requires-Dist: fastapi>=0.110; extra == "fastapi"
16
+ Provides-Extra: django
17
+ Requires-Dist: django>=4.2; extra == "django"
18
+
19
+ # fikiri-id-sdk
20
+
21
+ SDK officiel FikiriID pour Python. Intègre le flow OAuth2 Authorization Code + PKCE (RFC 7636) avec adaptateurs pour FastAPI et Django.
22
+
23
+ ## Installation
24
+
25
+ ```bash
26
+ pip install fikiri-id-sdk
27
+ # Avec FastAPI :
28
+ pip install "fikiri-id-sdk[fastapi]"
29
+ # Avec Django :
30
+ pip install "fikiri-id-sdk[django]"
31
+ ```
32
+
33
+ ## Usage de base
34
+
35
+ ```python
36
+ from fikiri_id_sdk import FikiriIDClient
37
+
38
+ with FikiriIDClient(
39
+ base_url="http://77.42.72.129:8094",
40
+ client_id="fikiri-AbCdEfGhIj",
41
+ client_secret="votre_secret",
42
+ redirect_uri="https://market.fikiri.cd/callback",
43
+ scopes=["openid", "profile", "email", "kyc:status"],
44
+ ) as client:
45
+ # 1. Générer l'URL de redirection (stocker code_verifier en session)
46
+ url, code_verifier = client.get_authorization_url(state="random-state")
47
+
48
+ # 2. Sur la route /callback
49
+ tokens = client.exchange_code(code="<code>", code_verifier=code_verifier)
50
+ user = client.get_userinfo(tokens["access_token"])
51
+ # → {"sub": "d569…", "fikiri_id": "06-002-1-1-1990-0042", "kyc_verified": True}
52
+ ```
53
+
54
+ ---
55
+
56
+ ## Intégration FastAPI
57
+
58
+ ```python
59
+ from fastapi import FastAPI, Depends
60
+ from fikiri_id_sdk import FikiriIDClient
61
+ from fikiri_id_sdk.fastapi import make_require_user, make_require_kyc
62
+
63
+ app = FastAPI()
64
+
65
+ fikiri = FikiriIDClient(
66
+ base_url="http://77.42.72.129:8094",
67
+ client_id="fikiri-AbCdEfGhIj",
68
+ client_secret="votre_secret",
69
+ redirect_uri="https://market.fikiri.cd/callback",
70
+ )
71
+
72
+ require_user = make_require_user(fikiri)
73
+ require_kyc = make_require_kyc(fikiri, min_level="verifie")
74
+
75
+ @app.get("/me")
76
+ def me(user=Depends(require_user)):
77
+ return {"fikiri_id": user["fikiri_id"], "name": user["name"]}
78
+
79
+ @app.post("/transfert")
80
+ def transfert(user=Depends(require_kyc)):
81
+ # Accessible uniquement aux utilisateurs KYC vérifié
82
+ return {"ok": True}
83
+
84
+ # Callback OAuth
85
+ @app.get("/callback")
86
+ def callback(code: str, state: str, request: Request):
87
+ code_verifier = request.session["code_verifier"]
88
+ tokens = fikiri.exchange_code(code=code, code_verifier=code_verifier)
89
+ return {"access_token": tokens["access_token"]}
90
+ ```
91
+
92
+ ---
93
+
94
+ ## Intégration Django
95
+
96
+ ```python
97
+ # settings.py
98
+ from fikiri_id_sdk import FikiriIDClient
99
+
100
+ FIKIRI_ID_CLIENT = FikiriIDClient(
101
+ base_url="http://77.42.72.129:8094",
102
+ client_id="fikiri-AbCdEfGhIj",
103
+ client_secret="votre_secret",
104
+ redirect_uri="https://market.fikiri.cd/callback",
105
+ )
106
+
107
+ MIDDLEWARE = [
108
+ ...
109
+ "fikiri_id_sdk.django.FikiriIDMiddleware",
110
+ ]
111
+ ```
112
+
113
+ ```python
114
+ # views.py
115
+ from django.http import JsonResponse
116
+ from fikiri_id_sdk.django import fikiri_login_required
117
+
118
+ @fikiri_login_required
119
+ def profil(request):
120
+ user = request.fikiri_user # dict introspect — jamais None ici
121
+ return JsonResponse({"fikiri_id": user["fikiri_id"]})
122
+
123
+ def publique(request):
124
+ user = getattr(request, "fikiri_user", None) # peut être None
125
+ return JsonResponse({"connecte": user is not None})
126
+ ```
127
+
128
+ ---
129
+
130
+ ## Client async (FastAPI / asyncio)
131
+
132
+ ```python
133
+ from fikiri_id_sdk import FikiriIDAsyncClient
134
+
135
+ async with FikiriIDAsyncClient(
136
+ base_url="http://77.42.72.129:8094",
137
+ client_id="fikiri-AbCdEfGhIj",
138
+ client_secret="votre_secret",
139
+ redirect_uri="https://market.fikiri.cd/callback",
140
+ ) as client:
141
+ tokens = await client.exchange_code(code, code_verifier)
142
+ user = await client.get_userinfo(tokens["access_token"])
143
+ info = await client.introspect(tokens["access_token"])
144
+ ```
145
+
146
+ ---
147
+
148
+ ## Méthodes disponibles
149
+
150
+ ### `FikiriIDClient` (sync) / `FikiriIDAsyncClient` (async)
151
+
152
+ | Méthode | Description |
153
+ |---------|-------------|
154
+ | `get_authorization_url(state)` | Retourne `(url, code_verifier)` — stocker le verifier en session |
155
+ | `exchange_code(code, code_verifier)` | Échange le code → `access_token` + `refresh_token` |
156
+ | `get_userinfo(access_token)` | Profil complet selon les scopes accordés |
157
+ | `introspect(token)` | Validation middleware RFC 7662 — `{"active": true/false, ...}` |
158
+ | `refresh_access_token(refresh_token)` | Renouvelle l'access token |
159
+ | `revoke_token(token)` | Révoque access ou refresh token à la déconnexion |
160
+ | `get_discovery()` | Configuration OIDC `/.well-known/openid-configuration` |
161
+
162
+ ### `make_require_user(client)` (FastAPI)
163
+
164
+ Retourne une dépendance FastAPI qui valide le Bearer token et injecte le profil.
165
+
166
+ ### `make_require_kyc(client, min_level)` (FastAPI)
167
+
168
+ Variante avec vérification du niveau KYC (`'en_cours'` ou `'verifie'`).
169
+
170
+ ### `FikiriIDMiddleware` (Django)
171
+
172
+ Injecte `request.fikiri_user` (dict introspect ou `None`) sur toutes les requêtes.
173
+
174
+ ### `fikiri_login_required` (Django)
175
+
176
+ Décorateur de vue — retourne 401 JSON si `request.fikiri_user` est `None`.
177
+
178
+ ---
179
+
180
+ Serveur live : **http://77.42.72.129:8094** · [Swagger UI](http://77.42.72.129:8094/docs) · [Guide d'intégration](../../docs/integration.md)
@@ -0,0 +1,162 @@
1
+ # fikiri-id-sdk
2
+
3
+ SDK officiel FikiriID pour Python. Intègre le flow OAuth2 Authorization Code + PKCE (RFC 7636) avec adaptateurs pour FastAPI et Django.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install fikiri-id-sdk
9
+ # Avec FastAPI :
10
+ pip install "fikiri-id-sdk[fastapi]"
11
+ # Avec Django :
12
+ pip install "fikiri-id-sdk[django]"
13
+ ```
14
+
15
+ ## Usage de base
16
+
17
+ ```python
18
+ from fikiri_id_sdk import FikiriIDClient
19
+
20
+ with FikiriIDClient(
21
+ base_url="http://77.42.72.129:8094",
22
+ client_id="fikiri-AbCdEfGhIj",
23
+ client_secret="votre_secret",
24
+ redirect_uri="https://market.fikiri.cd/callback",
25
+ scopes=["openid", "profile", "email", "kyc:status"],
26
+ ) as client:
27
+ # 1. Générer l'URL de redirection (stocker code_verifier en session)
28
+ url, code_verifier = client.get_authorization_url(state="random-state")
29
+
30
+ # 2. Sur la route /callback
31
+ tokens = client.exchange_code(code="<code>", code_verifier=code_verifier)
32
+ user = client.get_userinfo(tokens["access_token"])
33
+ # → {"sub": "d569…", "fikiri_id": "06-002-1-1-1990-0042", "kyc_verified": True}
34
+ ```
35
+
36
+ ---
37
+
38
+ ## Intégration FastAPI
39
+
40
+ ```python
41
+ from fastapi import FastAPI, Depends
42
+ from fikiri_id_sdk import FikiriIDClient
43
+ from fikiri_id_sdk.fastapi import make_require_user, make_require_kyc
44
+
45
+ app = FastAPI()
46
+
47
+ fikiri = FikiriIDClient(
48
+ base_url="http://77.42.72.129:8094",
49
+ client_id="fikiri-AbCdEfGhIj",
50
+ client_secret="votre_secret",
51
+ redirect_uri="https://market.fikiri.cd/callback",
52
+ )
53
+
54
+ require_user = make_require_user(fikiri)
55
+ require_kyc = make_require_kyc(fikiri, min_level="verifie")
56
+
57
+ @app.get("/me")
58
+ def me(user=Depends(require_user)):
59
+ return {"fikiri_id": user["fikiri_id"], "name": user["name"]}
60
+
61
+ @app.post("/transfert")
62
+ def transfert(user=Depends(require_kyc)):
63
+ # Accessible uniquement aux utilisateurs KYC vérifié
64
+ return {"ok": True}
65
+
66
+ # Callback OAuth
67
+ @app.get("/callback")
68
+ def callback(code: str, state: str, request: Request):
69
+ code_verifier = request.session["code_verifier"]
70
+ tokens = fikiri.exchange_code(code=code, code_verifier=code_verifier)
71
+ return {"access_token": tokens["access_token"]}
72
+ ```
73
+
74
+ ---
75
+
76
+ ## Intégration Django
77
+
78
+ ```python
79
+ # settings.py
80
+ from fikiri_id_sdk import FikiriIDClient
81
+
82
+ FIKIRI_ID_CLIENT = FikiriIDClient(
83
+ base_url="http://77.42.72.129:8094",
84
+ client_id="fikiri-AbCdEfGhIj",
85
+ client_secret="votre_secret",
86
+ redirect_uri="https://market.fikiri.cd/callback",
87
+ )
88
+
89
+ MIDDLEWARE = [
90
+ ...
91
+ "fikiri_id_sdk.django.FikiriIDMiddleware",
92
+ ]
93
+ ```
94
+
95
+ ```python
96
+ # views.py
97
+ from django.http import JsonResponse
98
+ from fikiri_id_sdk.django import fikiri_login_required
99
+
100
+ @fikiri_login_required
101
+ def profil(request):
102
+ user = request.fikiri_user # dict introspect — jamais None ici
103
+ return JsonResponse({"fikiri_id": user["fikiri_id"]})
104
+
105
+ def publique(request):
106
+ user = getattr(request, "fikiri_user", None) # peut être None
107
+ return JsonResponse({"connecte": user is not None})
108
+ ```
109
+
110
+ ---
111
+
112
+ ## Client async (FastAPI / asyncio)
113
+
114
+ ```python
115
+ from fikiri_id_sdk import FikiriIDAsyncClient
116
+
117
+ async with FikiriIDAsyncClient(
118
+ base_url="http://77.42.72.129:8094",
119
+ client_id="fikiri-AbCdEfGhIj",
120
+ client_secret="votre_secret",
121
+ redirect_uri="https://market.fikiri.cd/callback",
122
+ ) as client:
123
+ tokens = await client.exchange_code(code, code_verifier)
124
+ user = await client.get_userinfo(tokens["access_token"])
125
+ info = await client.introspect(tokens["access_token"])
126
+ ```
127
+
128
+ ---
129
+
130
+ ## Méthodes disponibles
131
+
132
+ ### `FikiriIDClient` (sync) / `FikiriIDAsyncClient` (async)
133
+
134
+ | Méthode | Description |
135
+ |---------|-------------|
136
+ | `get_authorization_url(state)` | Retourne `(url, code_verifier)` — stocker le verifier en session |
137
+ | `exchange_code(code, code_verifier)` | Échange le code → `access_token` + `refresh_token` |
138
+ | `get_userinfo(access_token)` | Profil complet selon les scopes accordés |
139
+ | `introspect(token)` | Validation middleware RFC 7662 — `{"active": true/false, ...}` |
140
+ | `refresh_access_token(refresh_token)` | Renouvelle l'access token |
141
+ | `revoke_token(token)` | Révoque access ou refresh token à la déconnexion |
142
+ | `get_discovery()` | Configuration OIDC `/.well-known/openid-configuration` |
143
+
144
+ ### `make_require_user(client)` (FastAPI)
145
+
146
+ Retourne une dépendance FastAPI qui valide le Bearer token et injecte le profil.
147
+
148
+ ### `make_require_kyc(client, min_level)` (FastAPI)
149
+
150
+ Variante avec vérification du niveau KYC (`'en_cours'` ou `'verifie'`).
151
+
152
+ ### `FikiriIDMiddleware` (Django)
153
+
154
+ Injecte `request.fikiri_user` (dict introspect ou `None`) sur toutes les requêtes.
155
+
156
+ ### `fikiri_login_required` (Django)
157
+
158
+ Décorateur de vue — retourne 401 JSON si `request.fikiri_user` est `None`.
159
+
160
+ ---
161
+
162
+ Serveur live : **http://77.42.72.129:8094** · [Swagger UI](http://77.42.72.129:8094/docs) · [Guide d'intégration](../../docs/integration.md)
@@ -0,0 +1,205 @@
1
+ """
2
+ fikiri-id-sdk — SDK officiel FikiriID (Python)
3
+
4
+ Intègre le flow OAuth2 Authorization Code + PKCE (RFC 7636) pour les
5
+ plateformes consommatrices Python de l'écosystème FIKIRI.
6
+
7
+ Usage rapide :
8
+ from fikiri_id_sdk import FikiriIDClient
9
+
10
+ client = FikiriIDClient(
11
+ base_url="http://77.42.72.129:8094",
12
+ client_id="fikiri-AbCdEfGhIj",
13
+ client_secret="votre_secret",
14
+ redirect_uri="https://market.fikiri.cd/callback",
15
+ )
16
+
17
+ # 1. Générer l'URL de redirection
18
+ url, code_verifier = client.get_authorization_url(state="random_state")
19
+ # → rediriger l'utilisateur vers url
20
+
21
+ # 2. Traiter le callback
22
+ tokens = client.exchange_code(code="<code>", code_verifier=code_verifier)
23
+
24
+ # 3. Récupérer le profil
25
+ user = client.get_userinfo(tokens["access_token"])
26
+ print(user["fikiri_id"], user["kyc_verified"])
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import base64
32
+ import hashlib
33
+ import secrets
34
+ from typing import Any
35
+
36
+ import httpx
37
+
38
+ __all__ = ["FikiriIDClient", "FikiriIDAsyncClient", "FikiriIDError"]
39
+
40
+
41
+ class FikiriIDError(Exception):
42
+ """Erreur retournée par FikiriID ou le serveur OIDC."""
43
+
44
+ def __init__(self, message: str, error_code: str = "unknown", status: int | None = None):
45
+ super().__init__(message)
46
+ self.error_code = error_code
47
+ self.status = status
48
+
49
+
50
+ def _generate_pkce() -> tuple[str, str]:
51
+ """Retourne (code_verifier, code_challenge) pour le flow PKCE S256."""
52
+ code_verifier = secrets.token_urlsafe(64)
53
+ digest = hashlib.sha256(code_verifier.encode()).digest()
54
+ code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
55
+ return code_verifier, code_challenge
56
+
57
+
58
+ class FikiriIDClient:
59
+ """
60
+ Client synchrone FikiriID (utilise httpx en mode sync).
61
+ Pour un usage async, instanciez avec httpx.AsyncClient et appelez
62
+ les méthodes préfixées _async (à venir).
63
+ """
64
+
65
+ def __init__(
66
+ self,
67
+ base_url: str,
68
+ client_id: str,
69
+ client_secret: str,
70
+ redirect_uri: str,
71
+ scopes: list[str] | None = None,
72
+ timeout: float = 10.0,
73
+ ):
74
+ self.base_url = base_url.rstrip("/")
75
+ self.client_id = client_id
76
+ self.client_secret = client_secret
77
+ self.redirect_uri = redirect_uri
78
+ self.scopes = scopes or ["openid", "profile", "email"]
79
+ self._http = httpx.Client(timeout=timeout)
80
+
81
+ # ── Authorization URL ──────────────────────────────────────────────────────
82
+
83
+ def get_authorization_url(self, state: str) -> tuple[str, str]:
84
+ """
85
+ Construit l'URL de redirection vers FikiriID et retourne
86
+ (url, code_verifier). Stockez code_verifier en session serveur
87
+ pour l'étape d'échange de code.
88
+ """
89
+ code_verifier, code_challenge = _generate_pkce()
90
+ params = {
91
+ "response_type": "code",
92
+ "client_id": self.client_id,
93
+ "redirect_uri": self.redirect_uri,
94
+ "scope": " ".join(self.scopes),
95
+ "state": state,
96
+ "code_challenge": code_challenge,
97
+ "code_challenge_method": "S256",
98
+ }
99
+ url = str(httpx.URL(f"{self.base_url}/auth/authorize", params=params))
100
+ return url, code_verifier
101
+
102
+ # ── Token endpoints ────────────────────────────────────────────────────────
103
+
104
+ def exchange_code(self, code: str, code_verifier: str) -> dict[str, Any]:
105
+ """
106
+ Étape 3 du flow PKCE : échange le code d'autorisation contre
107
+ access_token + refresh_token.
108
+ """
109
+ return self._post(
110
+ "/auth/token",
111
+ {
112
+ "grant_type": "authorization_code",
113
+ "code": code,
114
+ "redirect_uri": self.redirect_uri,
115
+ "code_verifier": code_verifier,
116
+ },
117
+ )
118
+
119
+ def refresh_access_token(self, refresh_token: str) -> dict[str, Any]:
120
+ """Obtient un nouvel access token depuis le refresh token."""
121
+ return self._post(
122
+ "/auth/token",
123
+ {"grant_type": "refresh_token", "refresh_token": refresh_token},
124
+ )
125
+
126
+ def revoke_token(self, token: str) -> None:
127
+ """Révoque un access ou refresh token. Appeler à la déconnexion."""
128
+ self._post("/auth/revoke", {"token": token})
129
+
130
+ # ── UserInfo ───────────────────────────────────────────────────────────────
131
+
132
+ def get_userinfo(self, access_token: str) -> dict[str, Any]:
133
+ """
134
+ Retourne le profil de l'utilisateur selon les scopes accordés.
135
+ Champs disponibles : sub, name, email, email_verified, fikiri_id,
136
+ kyc_status, kyc_verified, country_code, place_code, organisations…
137
+ """
138
+ res = self._http.get(
139
+ f"{self.base_url}/auth/userinfo",
140
+ headers={"Authorization": f"Bearer {access_token}"},
141
+ )
142
+ return self._handle(res)
143
+
144
+ # ── Introspection RFC 7662 ─────────────────────────────────────────────────
145
+
146
+ def introspect(self, token: str) -> dict[str, Any]:
147
+ """
148
+ Valide un access token côté serveur (RFC 7662).
149
+ Retourne {"active": True, "sub": ..., "scope": ..., "fikiri_id": ...}
150
+ ou {"active": False} si le token est invalide/expiré.
151
+
152
+ Préférer cette méthode à get_userinfo() dans les middlewares
153
+ d'authentification — une seule requête suffit.
154
+ """
155
+ return self._post("/auth/token/introspect", {"token": token})
156
+
157
+ # ── Discovery ──────────────────────────────────────────────────────────────
158
+
159
+ def get_discovery(self) -> dict[str, Any]:
160
+ """Récupère la configuration OIDC (/.well-known/openid-configuration)."""
161
+ res = self._http.get(f"{self.base_url}/.well-known/openid-configuration")
162
+ return self._handle(res)
163
+
164
+ # ── Internal ───────────────────────────────────────────────────────────────
165
+
166
+ def _post(self, path: str, data: dict[str, str]) -> dict[str, Any]:
167
+ creds = base64.b64encode(
168
+ f"{self.client_id}:{self.client_secret}".encode()
169
+ ).decode()
170
+ res = self._http.post(
171
+ f"{self.base_url}{path}",
172
+ data=data,
173
+ headers={
174
+ "Content-Type": "application/x-www-form-urlencoded",
175
+ "Authorization": f"Basic {creds}",
176
+ },
177
+ )
178
+ return self._handle(res)
179
+
180
+ @staticmethod
181
+ def _handle(res: httpx.Response) -> dict[str, Any]:
182
+ try:
183
+ body = res.json()
184
+ except Exception:
185
+ body = {"detail": res.text}
186
+ if not res.is_success:
187
+ raise FikiriIDError(
188
+ body.get("error_description") or body.get("detail") or "Erreur FikiriID",
189
+ error_code=body.get("error", "unknown"),
190
+ status=res.status_code,
191
+ )
192
+ return body # type: ignore[return-value]
193
+
194
+ def close(self) -> None:
195
+ """Ferme le client HTTP sous-jacent."""
196
+ self._http.close()
197
+
198
+ def __enter__(self) -> "FikiriIDClient":
199
+ return self
200
+
201
+ def __exit__(self, *_: Any) -> None:
202
+ self.close()
203
+
204
+
205
+ from fikiri_id_sdk.async_client import FikiriIDAsyncClient # noqa: E402
@@ -0,0 +1,134 @@
1
+ """Client async FikiriID (httpx.AsyncClient) — pour FastAPI et frameworks async."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from fikiri_id_sdk import FikiriIDError, _generate_pkce
11
+
12
+
13
+ class FikiriIDAsyncClient:
14
+ """
15
+ Client async FikiriID.
16
+
17
+ Usage dans FastAPI :
18
+ async with FikiriIDAsyncClient(...) as client:
19
+ url, verifier = client.get_authorization_url(state="xyz")
20
+ tokens = await client.exchange_code(code, verifier)
21
+ user = await client.get_userinfo(tokens["access_token"])
22
+ """
23
+
24
+ def __init__(
25
+ self,
26
+ base_url: str,
27
+ client_id: str,
28
+ client_secret: str,
29
+ redirect_uri: str,
30
+ scopes: list[str] | None = None,
31
+ timeout: float = 10.0,
32
+ ):
33
+ self.base_url = base_url.rstrip("/")
34
+ self.client_id = client_id
35
+ self.client_secret = client_secret
36
+ self.redirect_uri = redirect_uri
37
+ self.scopes = scopes or ["openid", "profile", "email"]
38
+ self._http = httpx.AsyncClient(timeout=timeout)
39
+
40
+ def get_authorization_url(self, state: str) -> tuple[str, str]:
41
+ """Retourne (url, code_verifier) — synchrone, pas d'I/O."""
42
+ code_verifier, code_challenge = _generate_pkce()
43
+ params = {
44
+ "response_type": "code",
45
+ "client_id": self.client_id,
46
+ "redirect_uri": self.redirect_uri,
47
+ "scope": " ".join(self.scopes),
48
+ "state": state,
49
+ "code_challenge": code_challenge,
50
+ "code_challenge_method": "S256",
51
+ }
52
+ url = str(httpx.URL(f"{self.base_url}/auth/authorize", params=params))
53
+ return url, code_verifier
54
+
55
+ async def exchange_code(self, code: str, code_verifier: str) -> dict[str, Any]:
56
+ """Échange le code d'autorisation contre access_token + refresh_token."""
57
+ return await self._post(
58
+ "/auth/token",
59
+ {
60
+ "grant_type": "authorization_code",
61
+ "code": code,
62
+ "redirect_uri": self.redirect_uri,
63
+ "code_verifier": code_verifier,
64
+ },
65
+ )
66
+
67
+ async def refresh_access_token(self, refresh_token: str) -> dict[str, Any]:
68
+ """Obtient un nouvel access token depuis le refresh token."""
69
+ return await self._post(
70
+ "/auth/token",
71
+ {"grant_type": "refresh_token", "refresh_token": refresh_token},
72
+ )
73
+
74
+ async def revoke_token(self, token: str) -> None:
75
+ """Révoque un access ou refresh token. Appeler à la déconnexion."""
76
+ await self._post("/auth/revoke", {"token": token})
77
+
78
+ async def get_userinfo(self, access_token: str) -> dict[str, Any]:
79
+ """Profil de l'utilisateur selon les scopes accordés."""
80
+ res = await self._http.get(
81
+ f"{self.base_url}/auth/userinfo",
82
+ headers={"Authorization": f"Bearer {access_token}"},
83
+ )
84
+ return self._handle(res)
85
+
86
+ async def introspect(self, token: str) -> dict[str, Any]:
87
+ """
88
+ Valide un access token côté serveur (RFC 7662).
89
+ Retourne {"active": True, "sub": ..., "fikiri_id": ...}
90
+ ou {"active": False} si invalide/expiré.
91
+ """
92
+ return await self._post("/auth/token/introspect", {"token": token})
93
+
94
+ async def get_discovery(self) -> dict[str, Any]:
95
+ """Configuration OIDC /.well-known/openid-configuration."""
96
+ res = await self._http.get(f"{self.base_url}/.well-known/openid-configuration")
97
+ return self._handle(res)
98
+
99
+ async def _post(self, path: str, data: dict[str, str]) -> dict[str, Any]:
100
+ creds = base64.b64encode(
101
+ f"{self.client_id}:{self.client_secret}".encode()
102
+ ).decode()
103
+ res = await self._http.post(
104
+ f"{self.base_url}{path}",
105
+ data=data,
106
+ headers={
107
+ "Content-Type": "application/x-www-form-urlencoded",
108
+ "Authorization": f"Basic {creds}",
109
+ },
110
+ )
111
+ return self._handle(res)
112
+
113
+ @staticmethod
114
+ def _handle(res: httpx.Response) -> dict[str, Any]:
115
+ try:
116
+ body = res.json()
117
+ except Exception:
118
+ body = {"detail": res.text}
119
+ if not res.is_success:
120
+ raise FikiriIDError(
121
+ body.get("error_description") or body.get("detail") or "Erreur FikiriID",
122
+ error_code=body.get("error", "unknown"),
123
+ status=res.status_code,
124
+ )
125
+ return body # type: ignore[return-value]
126
+
127
+ async def close(self) -> None:
128
+ await self._http.aclose()
129
+
130
+ async def __aenter__(self) -> "FikiriIDAsyncClient":
131
+ return self
132
+
133
+ async def __aexit__(self, *_: Any) -> None:
134
+ await self.close()