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.
- fikiri_id_sdk-0.1.0/PKG-INFO +180 -0
- fikiri_id_sdk-0.1.0/README.md +162 -0
- fikiri_id_sdk-0.1.0/fikiri_id_sdk/__init__.py +205 -0
- fikiri_id_sdk-0.1.0/fikiri_id_sdk/async_client.py +134 -0
- fikiri_id_sdk-0.1.0/fikiri_id_sdk/django.py +102 -0
- fikiri_id_sdk-0.1.0/fikiri_id_sdk/fastapi.py +99 -0
- fikiri_id_sdk-0.1.0/fikiri_id_sdk/py.typed +0 -0
- fikiri_id_sdk-0.1.0/fikiri_id_sdk.egg-info/PKG-INFO +180 -0
- fikiri_id_sdk-0.1.0/fikiri_id_sdk.egg-info/SOURCES.txt +13 -0
- fikiri_id_sdk-0.1.0/fikiri_id_sdk.egg-info/dependency_links.txt +1 -0
- fikiri_id_sdk-0.1.0/fikiri_id_sdk.egg-info/requires.txt +12 -0
- fikiri_id_sdk-0.1.0/fikiri_id_sdk.egg-info/top_level.txt +1 -0
- fikiri_id_sdk-0.1.0/pyproject.toml +27 -0
- fikiri_id_sdk-0.1.0/setup.cfg +4 -0
- fikiri_id_sdk-0.1.0/tests/test_client.py +258 -0
|
@@ -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()
|