easy-oauth 0.0.4__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.
- easy_oauth-0.0.4/PKG-INFO +301 -0
- easy_oauth-0.0.4/README.md +285 -0
- easy_oauth-0.0.4/pyproject.toml +44 -0
- easy_oauth-0.0.4/src/easy_oauth/__init__.py +4 -0
- easy_oauth-0.0.4/src/easy_oauth/cap.py +71 -0
- easy_oauth-0.0.4/src/easy_oauth/manager.py +337 -0
- easy_oauth-0.0.4/src/easy_oauth/py.typed +0 -0
- easy_oauth-0.0.4/src/easy_oauth/structs.py +68 -0
- easy_oauth-0.0.4/src/easy_oauth/testing/__init__.py +0 -0
- easy_oauth-0.0.4/src/easy_oauth/testing/oauth_mock.py +356 -0
- easy_oauth-0.0.4/src/easy_oauth/testing/utils.py +177 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: easy-oauth
|
|
3
|
+
Version: 0.0.4
|
|
4
|
+
Summary: Easy OAuth authentication for Starlette/FastAPI apps
|
|
5
|
+
Author: Olivier Breuleux
|
|
6
|
+
Author-email: Olivier Breuleux <breuleux@gmail.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Requires-Dist: authlib>=1.6.5
|
|
9
|
+
Requires-Dist: httpx>=0.28.1
|
|
10
|
+
Requires-Dist: itsdangerous>=2.2.0
|
|
11
|
+
Requires-Dist: pyyaml>=6.0.3
|
|
12
|
+
Requires-Dist: serieux>=0.3.5
|
|
13
|
+
Requires-Dist: starlette>=0.50.0
|
|
14
|
+
Requires-Python: >=3.12
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# easy-oauth
|
|
19
|
+
|
|
20
|
+
Small package to add OAuth-based authentication to a Starlette/FastAPI app. Users may also retrieve a token to authenticate themselves.
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
`uv add git+https://github.com/mila-iqia/easy-oauth@v0.0.2`
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
If you want to authenticate through Google, first you will need to create a project in GCP and get a client_id and client_secret from the console. Then you can do it like this:
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from easy_oauth import OAuthManager, CapabilitySet
|
|
35
|
+
|
|
36
|
+
oauth = OAuthManager(
|
|
37
|
+
# This page describes where the endpoint urls are defined
|
|
38
|
+
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
|
|
39
|
+
# A secret key to encrypt the session and tokens, you can generate it yourself
|
|
40
|
+
secret_key=SECRET_KEY,
|
|
41
|
+
# The client id from Google Console
|
|
42
|
+
client_id=CLIENT_ID,
|
|
43
|
+
# The client secret from Google Console
|
|
44
|
+
client_secret=CLIENT_SECRET,
|
|
45
|
+
# Arguments to the auth request, you can just use what's below
|
|
46
|
+
client_kwargs={
|
|
47
|
+
"scope": "openid email",
|
|
48
|
+
"prompt": "select_account",
|
|
49
|
+
}
|
|
50
|
+
# Set of capabilities that can be assigned to users
|
|
51
|
+
capabilities=CapabilitySet(
|
|
52
|
+
graph={
|
|
53
|
+
# Basic capability
|
|
54
|
+
"read": [],
|
|
55
|
+
# write, also implies read
|
|
56
|
+
"write": ["read"],
|
|
57
|
+
# moderate, also implies read and write
|
|
58
|
+
"moderate": ["read", "write"],
|
|
59
|
+
# announce, also implies read and write
|
|
60
|
+
"announce": ["read", "write"],
|
|
61
|
+
# "user_management" is the capability needed to set the capabilities of
|
|
62
|
+
# users.
|
|
63
|
+
"user_management": [],
|
|
64
|
+
},
|
|
65
|
+
# Create the "admin" capability that has every other capability
|
|
66
|
+
auto_admin=True,
|
|
67
|
+
# File where each user's capability is stored
|
|
68
|
+
user_file="caps.yaml",
|
|
69
|
+
# Capabilities granted to all authenticated users
|
|
70
|
+
default_capabilities=["read"],
|
|
71
|
+
# Capabilities granted to unauthenticated users (guests)
|
|
72
|
+
guest_capabilities=[],
|
|
73
|
+
),
|
|
74
|
+
# If you want routes to be at /api/v1/login etc., put "/api/v1" here
|
|
75
|
+
prefix="",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
app = FastAPI()
|
|
79
|
+
|
|
80
|
+
oauth.install(app)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Here is an example of a user capability file:
|
|
84
|
+
|
|
85
|
+
```yaml
|
|
86
|
+
your-email@you.com:
|
|
87
|
+
- admin
|
|
88
|
+
friend@rainbows.com:
|
|
89
|
+
- moderate
|
|
90
|
+
- announce
|
|
91
|
+
pierre-jean-jacques@youhou.fr:
|
|
92
|
+
- read
|
|
93
|
+
hateful-being@cornhole.co: []
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
In order to require a certain capability for a given route, you can declare it like this in FastAPI:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
@app.get("/shout")
|
|
100
|
+
async def route_shout(
|
|
101
|
+
request: Request,
|
|
102
|
+
message: str,
|
|
103
|
+
email: str = Depends(oauth.get_email_capability("announce", redirect=True)),
|
|
104
|
+
):
|
|
105
|
+
return PlainTextResponse(f"{email} shouts {message!r}")
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
If `redirect=True` in `get_email_capability`, then the browser will redirect to the login page if the user is not logged in, then it will redirect back to the original page.
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
### Token workflow
|
|
112
|
+
|
|
113
|
+
First, the user should point their browser to the `/token` endpoint. This will prompt them to log in and it will display a token. Copy it.
|
|
114
|
+
|
|
115
|
+
Then you can use use Bearer authentication with the token. That is to say, the `Authorization` header should be set to `Bearer INSERT_TOKEN_HERE`. Using `httpx`, for example (it should work the same with `requests`):
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
# Unauthorized access
|
|
119
|
+
assert httpx.get(f"{app_url}/something").status_code == 401
|
|
120
|
+
|
|
121
|
+
# Authorized access
|
|
122
|
+
assert httpx.get(f"{app_url}/something", headers={"Authorization": f"Bearer {token}"}).status_code == 200
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
### Reading configuration from a file
|
|
127
|
+
|
|
128
|
+
The configuration for the above OAuthManager can be written in a file, like this:
|
|
129
|
+
|
|
130
|
+
```yaml
|
|
131
|
+
server_metadata_url: https://accounts.google.com/.well-known/openid-configuration
|
|
132
|
+
secret_key: "<SECRET_KEY>"
|
|
133
|
+
client_id: "<CLIENT_ID>"
|
|
134
|
+
client_secret: "<CLIENT_SECRET>"
|
|
135
|
+
client_kwargs:
|
|
136
|
+
scope: openid email
|
|
137
|
+
prompt: select_account
|
|
138
|
+
capabilities:
|
|
139
|
+
graph:
|
|
140
|
+
read: []
|
|
141
|
+
write: [read]
|
|
142
|
+
moderate: [read, write]
|
|
143
|
+
announce: [read, write]
|
|
144
|
+
user_management: []
|
|
145
|
+
auto_admin: true
|
|
146
|
+
user_file: caps.yaml
|
|
147
|
+
default_capabilities: [read]
|
|
148
|
+
guest_capabilities: []
|
|
149
|
+
prefix: ""
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
And instantiated like this:
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
from serieux import deserialize
|
|
156
|
+
|
|
157
|
+
oauth = deserialize(OAuthManager, Path("config.yaml"))
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Of course, you can nest that configuration within a larger configuration.
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
### Encrypting the configuration
|
|
164
|
+
|
|
165
|
+
The secrets written in the config file can be encrypted using `serieux` (The `-m` option must point to the type of the root of the configuration using the syntax `module:symbol`, in this case it is simply `easy_oauth:OAuthManager`):
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
export SERIEUX_PASSWORD="change_me!!1"
|
|
169
|
+
serieux patch -m easy_oauth:OAuthManager -f config.yaml
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
You must then modify the instantiation code like this:
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
import os
|
|
176
|
+
from serieux import deserialize
|
|
177
|
+
from serieux.features.encrypt import EncryptionKey
|
|
178
|
+
|
|
179
|
+
oauth = deserialize(OAuthManager, Path("config.yaml"), EncryptionKey(os.getenv("SERIEUX_PASSWORD")))
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## Routes
|
|
183
|
+
|
|
184
|
+
The OAuthManager automatically adds the following routes when installed on your Starlette/FastAPI application:
|
|
185
|
+
|
|
186
|
+
### Authentication Routes
|
|
187
|
+
|
|
188
|
+
- **GET `/login`**
|
|
189
|
+
- Initiates the OAuth login flow
|
|
190
|
+
- Clears the current session and redirects to the OAuth provider
|
|
191
|
+
- Query parameters:
|
|
192
|
+
- `redirect` (optional): Name of the auth callback route (default: `auth`)
|
|
193
|
+
- `offline_token=true` (optional): Request a refresh token with offline access
|
|
194
|
+
- Stores the original URL in session to redirect back after authentication
|
|
195
|
+
|
|
196
|
+
- **GET `/auth`**
|
|
197
|
+
- OAuth callback route that handles the authorization code
|
|
198
|
+
- Exchanges the authorization code for tokens and stores user information in the session
|
|
199
|
+
- Redirects to the original URL (default: `/`)
|
|
200
|
+
|
|
201
|
+
- **GET `/token`**
|
|
202
|
+
- Returns an encrypted refresh token for the authenticated user
|
|
203
|
+
- Response: `{"refresh_token": "<encrypted_token>"}`
|
|
204
|
+
|
|
205
|
+
- **GET `/logout`**
|
|
206
|
+
- Clears the user session and redirects to `/`
|
|
207
|
+
|
|
208
|
+
### Capability Management Routes
|
|
209
|
+
|
|
210
|
+
- **GET `/manage_capabilities/list`**
|
|
211
|
+
- Lists capabilities for a user
|
|
212
|
+
- Query parameters:
|
|
213
|
+
- `email` (optional): Email address to query (defaults to current user)
|
|
214
|
+
- Requires user management capability if querying another user's capabilities
|
|
215
|
+
- Response: `{"status": "ok", "email": "<email>", "capabilities": [...]}`
|
|
216
|
+
|
|
217
|
+
The following routes are only added if there is a `user_management` capability:
|
|
218
|
+
|
|
219
|
+
- **POST `/manage_capabilities/add`**
|
|
220
|
+
- Adds a capability to a user
|
|
221
|
+
- Requires user management capability
|
|
222
|
+
- Request body: `{"email": "<email>", "capability": "<capability_name>"}`
|
|
223
|
+
- Response: `{"status": "ok", "email": "<email>", "capabilities": [...]}`
|
|
224
|
+
|
|
225
|
+
- **POST `/manage_capabilities/remove`**
|
|
226
|
+
- Removes a capability from a user
|
|
227
|
+
- Requires user management capability
|
|
228
|
+
- Request body: `{"email": "<email>", "capability": "<capability_name>"}`
|
|
229
|
+
- Response: `{"status": "ok", "email": "<email>", "capabilities": [...]}`
|
|
230
|
+
|
|
231
|
+
- **POST `/manage_capabilities/set`**
|
|
232
|
+
- Sets the complete capability set for a user (replaces existing capabilities)
|
|
233
|
+
- Requires user management capability
|
|
234
|
+
- Request body: `{"email": "<email>", "capabilities": ["<cap1>", "<cap2>", ...]}`
|
|
235
|
+
- Response: `{"status": "ok", "email": "<email>", "capabilities": [...]}`
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
## Testing
|
|
239
|
+
|
|
240
|
+
For testing, easy_oauth defines a mock OAuth server that always logs you in unconditionally as `test@example.com` by default. That way you don't need a browser or any secrets to test things.
|
|
241
|
+
|
|
242
|
+
```bash
|
|
243
|
+
uvicorn easy_oauth.testing.oauth_mock:app
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
To set the email address the mock OAuth server with authentify all requests as, send a POST request with JSON data like this:
|
|
247
|
+
|
|
248
|
+
```bash
|
|
249
|
+
curl -X POST -H "Content-Type: application/json" -d '{"email": "a@b.c"}' http://127.0.0.1:8000/set_email
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
To use it with easy_oauth, set `server_metadata_url` to `http://127.0.0.1:8000/.well-known/openid-configuration` (depending on the host and port).
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
### Fixtures
|
|
256
|
+
|
|
257
|
+
easy-oauth provides the `OAuthMock` and `AppTester` classes to make testing easier. Here is a very simple example of how to use them:
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
```python
|
|
261
|
+
from easy_oauth.testing.utils import AppTester, OAuthMock
|
|
262
|
+
|
|
263
|
+
@pytest.fixture(scope="session")
|
|
264
|
+
def oauth_mock():
|
|
265
|
+
# Start one mock oauth server for the session. It's important that the
|
|
266
|
+
# OAUTH_PORT conforms to the server_metadata_url you configure the test app
|
|
267
|
+
# with
|
|
268
|
+
with OAuthMock(port=OAUTH_PORT) as oauth:
|
|
269
|
+
yield oauth
|
|
270
|
+
|
|
271
|
+
@pytest.fixture(scope="session")
|
|
272
|
+
def app(oauth_mock):
|
|
273
|
+
# This doesn't have to be session-scoped, but if your app is read-only it may
|
|
274
|
+
# as well be.
|
|
275
|
+
with AppTester(your_app, oauth_mock) as appt:
|
|
276
|
+
yield appt
|
|
277
|
+
|
|
278
|
+
def test_view_payroll(app):
|
|
279
|
+
# Use app.client to pretend to be various users
|
|
280
|
+
guest = app.client()
|
|
281
|
+
user = app.client("simple.user@website.web")
|
|
282
|
+
accountant = app.client("mr.bean@website.web")
|
|
283
|
+
admin = app.client("admin@website.web")
|
|
284
|
+
|
|
285
|
+
# Guests are not authentified (so we expect HTTP error 401)
|
|
286
|
+
guest.get("/payroll/view", expect=401)
|
|
287
|
+
# Normal users are unauthorized to view the payroll
|
|
288
|
+
user.get("/payroll/view", expect=403)
|
|
289
|
+
# Accountants and admins are authorized
|
|
290
|
+
accountant.get("/payroll/view", expect=200)
|
|
291
|
+
admin.get("/payroll/view", expect=200)
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
## TODO
|
|
296
|
+
|
|
297
|
+
There are a few things that need to be done in the future:
|
|
298
|
+
|
|
299
|
+
* Add an endpoint to revoke tokens.
|
|
300
|
+
* Users with `user_management` capability should only be able to add/remove capabilities that they have.
|
|
301
|
+
* API tokens associated to capabilities but not accounts
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
|
|
2
|
+
# easy-oauth
|
|
3
|
+
|
|
4
|
+
Small package to add OAuth-based authentication to a Starlette/FastAPI app. Users may also retrieve a token to authenticate themselves.
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
`uv add git+https://github.com/mila-iqia/easy-oauth@v0.0.2`
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
## Usage
|
|
13
|
+
|
|
14
|
+
If you want to authenticate through Google, first you will need to create a project in GCP and get a client_id and client_secret from the console. Then you can do it like this:
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from easy_oauth import OAuthManager, CapabilitySet
|
|
19
|
+
|
|
20
|
+
oauth = OAuthManager(
|
|
21
|
+
# This page describes where the endpoint urls are defined
|
|
22
|
+
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
|
|
23
|
+
# A secret key to encrypt the session and tokens, you can generate it yourself
|
|
24
|
+
secret_key=SECRET_KEY,
|
|
25
|
+
# The client id from Google Console
|
|
26
|
+
client_id=CLIENT_ID,
|
|
27
|
+
# The client secret from Google Console
|
|
28
|
+
client_secret=CLIENT_SECRET,
|
|
29
|
+
# Arguments to the auth request, you can just use what's below
|
|
30
|
+
client_kwargs={
|
|
31
|
+
"scope": "openid email",
|
|
32
|
+
"prompt": "select_account",
|
|
33
|
+
}
|
|
34
|
+
# Set of capabilities that can be assigned to users
|
|
35
|
+
capabilities=CapabilitySet(
|
|
36
|
+
graph={
|
|
37
|
+
# Basic capability
|
|
38
|
+
"read": [],
|
|
39
|
+
# write, also implies read
|
|
40
|
+
"write": ["read"],
|
|
41
|
+
# moderate, also implies read and write
|
|
42
|
+
"moderate": ["read", "write"],
|
|
43
|
+
# announce, also implies read and write
|
|
44
|
+
"announce": ["read", "write"],
|
|
45
|
+
# "user_management" is the capability needed to set the capabilities of
|
|
46
|
+
# users.
|
|
47
|
+
"user_management": [],
|
|
48
|
+
},
|
|
49
|
+
# Create the "admin" capability that has every other capability
|
|
50
|
+
auto_admin=True,
|
|
51
|
+
# File where each user's capability is stored
|
|
52
|
+
user_file="caps.yaml",
|
|
53
|
+
# Capabilities granted to all authenticated users
|
|
54
|
+
default_capabilities=["read"],
|
|
55
|
+
# Capabilities granted to unauthenticated users (guests)
|
|
56
|
+
guest_capabilities=[],
|
|
57
|
+
),
|
|
58
|
+
# If you want routes to be at /api/v1/login etc., put "/api/v1" here
|
|
59
|
+
prefix="",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
app = FastAPI()
|
|
63
|
+
|
|
64
|
+
oauth.install(app)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Here is an example of a user capability file:
|
|
68
|
+
|
|
69
|
+
```yaml
|
|
70
|
+
your-email@you.com:
|
|
71
|
+
- admin
|
|
72
|
+
friend@rainbows.com:
|
|
73
|
+
- moderate
|
|
74
|
+
- announce
|
|
75
|
+
pierre-jean-jacques@youhou.fr:
|
|
76
|
+
- read
|
|
77
|
+
hateful-being@cornhole.co: []
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
In order to require a certain capability for a given route, you can declare it like this in FastAPI:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
@app.get("/shout")
|
|
84
|
+
async def route_shout(
|
|
85
|
+
request: Request,
|
|
86
|
+
message: str,
|
|
87
|
+
email: str = Depends(oauth.get_email_capability("announce", redirect=True)),
|
|
88
|
+
):
|
|
89
|
+
return PlainTextResponse(f"{email} shouts {message!r}")
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
If `redirect=True` in `get_email_capability`, then the browser will redirect to the login page if the user is not logged in, then it will redirect back to the original page.
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
### Token workflow
|
|
96
|
+
|
|
97
|
+
First, the user should point their browser to the `/token` endpoint. This will prompt them to log in and it will display a token. Copy it.
|
|
98
|
+
|
|
99
|
+
Then you can use use Bearer authentication with the token. That is to say, the `Authorization` header should be set to `Bearer INSERT_TOKEN_HERE`. Using `httpx`, for example (it should work the same with `requests`):
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
# Unauthorized access
|
|
103
|
+
assert httpx.get(f"{app_url}/something").status_code == 401
|
|
104
|
+
|
|
105
|
+
# Authorized access
|
|
106
|
+
assert httpx.get(f"{app_url}/something", headers={"Authorization": f"Bearer {token}"}).status_code == 200
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
### Reading configuration from a file
|
|
111
|
+
|
|
112
|
+
The configuration for the above OAuthManager can be written in a file, like this:
|
|
113
|
+
|
|
114
|
+
```yaml
|
|
115
|
+
server_metadata_url: https://accounts.google.com/.well-known/openid-configuration
|
|
116
|
+
secret_key: "<SECRET_KEY>"
|
|
117
|
+
client_id: "<CLIENT_ID>"
|
|
118
|
+
client_secret: "<CLIENT_SECRET>"
|
|
119
|
+
client_kwargs:
|
|
120
|
+
scope: openid email
|
|
121
|
+
prompt: select_account
|
|
122
|
+
capabilities:
|
|
123
|
+
graph:
|
|
124
|
+
read: []
|
|
125
|
+
write: [read]
|
|
126
|
+
moderate: [read, write]
|
|
127
|
+
announce: [read, write]
|
|
128
|
+
user_management: []
|
|
129
|
+
auto_admin: true
|
|
130
|
+
user_file: caps.yaml
|
|
131
|
+
default_capabilities: [read]
|
|
132
|
+
guest_capabilities: []
|
|
133
|
+
prefix: ""
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
And instantiated like this:
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
from serieux import deserialize
|
|
140
|
+
|
|
141
|
+
oauth = deserialize(OAuthManager, Path("config.yaml"))
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Of course, you can nest that configuration within a larger configuration.
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
### Encrypting the configuration
|
|
148
|
+
|
|
149
|
+
The secrets written in the config file can be encrypted using `serieux` (The `-m` option must point to the type of the root of the configuration using the syntax `module:symbol`, in this case it is simply `easy_oauth:OAuthManager`):
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
export SERIEUX_PASSWORD="change_me!!1"
|
|
153
|
+
serieux patch -m easy_oauth:OAuthManager -f config.yaml
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
You must then modify the instantiation code like this:
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
import os
|
|
160
|
+
from serieux import deserialize
|
|
161
|
+
from serieux.features.encrypt import EncryptionKey
|
|
162
|
+
|
|
163
|
+
oauth = deserialize(OAuthManager, Path("config.yaml"), EncryptionKey(os.getenv("SERIEUX_PASSWORD")))
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## Routes
|
|
167
|
+
|
|
168
|
+
The OAuthManager automatically adds the following routes when installed on your Starlette/FastAPI application:
|
|
169
|
+
|
|
170
|
+
### Authentication Routes
|
|
171
|
+
|
|
172
|
+
- **GET `/login`**
|
|
173
|
+
- Initiates the OAuth login flow
|
|
174
|
+
- Clears the current session and redirects to the OAuth provider
|
|
175
|
+
- Query parameters:
|
|
176
|
+
- `redirect` (optional): Name of the auth callback route (default: `auth`)
|
|
177
|
+
- `offline_token=true` (optional): Request a refresh token with offline access
|
|
178
|
+
- Stores the original URL in session to redirect back after authentication
|
|
179
|
+
|
|
180
|
+
- **GET `/auth`**
|
|
181
|
+
- OAuth callback route that handles the authorization code
|
|
182
|
+
- Exchanges the authorization code for tokens and stores user information in the session
|
|
183
|
+
- Redirects to the original URL (default: `/`)
|
|
184
|
+
|
|
185
|
+
- **GET `/token`**
|
|
186
|
+
- Returns an encrypted refresh token for the authenticated user
|
|
187
|
+
- Response: `{"refresh_token": "<encrypted_token>"}`
|
|
188
|
+
|
|
189
|
+
- **GET `/logout`**
|
|
190
|
+
- Clears the user session and redirects to `/`
|
|
191
|
+
|
|
192
|
+
### Capability Management Routes
|
|
193
|
+
|
|
194
|
+
- **GET `/manage_capabilities/list`**
|
|
195
|
+
- Lists capabilities for a user
|
|
196
|
+
- Query parameters:
|
|
197
|
+
- `email` (optional): Email address to query (defaults to current user)
|
|
198
|
+
- Requires user management capability if querying another user's capabilities
|
|
199
|
+
- Response: `{"status": "ok", "email": "<email>", "capabilities": [...]}`
|
|
200
|
+
|
|
201
|
+
The following routes are only added if there is a `user_management` capability:
|
|
202
|
+
|
|
203
|
+
- **POST `/manage_capabilities/add`**
|
|
204
|
+
- Adds a capability to a user
|
|
205
|
+
- Requires user management capability
|
|
206
|
+
- Request body: `{"email": "<email>", "capability": "<capability_name>"}`
|
|
207
|
+
- Response: `{"status": "ok", "email": "<email>", "capabilities": [...]}`
|
|
208
|
+
|
|
209
|
+
- **POST `/manage_capabilities/remove`**
|
|
210
|
+
- Removes a capability from a user
|
|
211
|
+
- Requires user management capability
|
|
212
|
+
- Request body: `{"email": "<email>", "capability": "<capability_name>"}`
|
|
213
|
+
- Response: `{"status": "ok", "email": "<email>", "capabilities": [...]}`
|
|
214
|
+
|
|
215
|
+
- **POST `/manage_capabilities/set`**
|
|
216
|
+
- Sets the complete capability set for a user (replaces existing capabilities)
|
|
217
|
+
- Requires user management capability
|
|
218
|
+
- Request body: `{"email": "<email>", "capabilities": ["<cap1>", "<cap2>", ...]}`
|
|
219
|
+
- Response: `{"status": "ok", "email": "<email>", "capabilities": [...]}`
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
## Testing
|
|
223
|
+
|
|
224
|
+
For testing, easy_oauth defines a mock OAuth server that always logs you in unconditionally as `test@example.com` by default. That way you don't need a browser or any secrets to test things.
|
|
225
|
+
|
|
226
|
+
```bash
|
|
227
|
+
uvicorn easy_oauth.testing.oauth_mock:app
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
To set the email address the mock OAuth server with authentify all requests as, send a POST request with JSON data like this:
|
|
231
|
+
|
|
232
|
+
```bash
|
|
233
|
+
curl -X POST -H "Content-Type: application/json" -d '{"email": "a@b.c"}' http://127.0.0.1:8000/set_email
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
To use it with easy_oauth, set `server_metadata_url` to `http://127.0.0.1:8000/.well-known/openid-configuration` (depending on the host and port).
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
### Fixtures
|
|
240
|
+
|
|
241
|
+
easy-oauth provides the `OAuthMock` and `AppTester` classes to make testing easier. Here is a very simple example of how to use them:
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
```python
|
|
245
|
+
from easy_oauth.testing.utils import AppTester, OAuthMock
|
|
246
|
+
|
|
247
|
+
@pytest.fixture(scope="session")
|
|
248
|
+
def oauth_mock():
|
|
249
|
+
# Start one mock oauth server for the session. It's important that the
|
|
250
|
+
# OAUTH_PORT conforms to the server_metadata_url you configure the test app
|
|
251
|
+
# with
|
|
252
|
+
with OAuthMock(port=OAUTH_PORT) as oauth:
|
|
253
|
+
yield oauth
|
|
254
|
+
|
|
255
|
+
@pytest.fixture(scope="session")
|
|
256
|
+
def app(oauth_mock):
|
|
257
|
+
# This doesn't have to be session-scoped, but if your app is read-only it may
|
|
258
|
+
# as well be.
|
|
259
|
+
with AppTester(your_app, oauth_mock) as appt:
|
|
260
|
+
yield appt
|
|
261
|
+
|
|
262
|
+
def test_view_payroll(app):
|
|
263
|
+
# Use app.client to pretend to be various users
|
|
264
|
+
guest = app.client()
|
|
265
|
+
user = app.client("simple.user@website.web")
|
|
266
|
+
accountant = app.client("mr.bean@website.web")
|
|
267
|
+
admin = app.client("admin@website.web")
|
|
268
|
+
|
|
269
|
+
# Guests are not authentified (so we expect HTTP error 401)
|
|
270
|
+
guest.get("/payroll/view", expect=401)
|
|
271
|
+
# Normal users are unauthorized to view the payroll
|
|
272
|
+
user.get("/payroll/view", expect=403)
|
|
273
|
+
# Accountants and admins are authorized
|
|
274
|
+
accountant.get("/payroll/view", expect=200)
|
|
275
|
+
admin.get("/payroll/view", expect=200)
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
## TODO
|
|
280
|
+
|
|
281
|
+
There are a few things that need to be done in the future:
|
|
282
|
+
|
|
283
|
+
* Add an endpoint to revoke tokens.
|
|
284
|
+
* Users with `user_management` capability should only be able to add/remove capabilities that they have.
|
|
285
|
+
* API tokens associated to capabilities but not accounts
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "easy-oauth"
|
|
3
|
+
version = "0.0.4"
|
|
4
|
+
description = "Easy OAuth authentication for Starlette/FastAPI apps"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Olivier Breuleux", email = "breuleux@gmail.com" }
|
|
8
|
+
]
|
|
9
|
+
license = "MIT"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"authlib>=1.6.5",
|
|
13
|
+
"httpx>=0.28.1",
|
|
14
|
+
"itsdangerous>=2.2.0",
|
|
15
|
+
"pyyaml>=6.0.3",
|
|
16
|
+
"serieux>=0.3.5",
|
|
17
|
+
"starlette>=0.50.0",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[build-system]
|
|
21
|
+
requires = ["uv_build>=0.8.22,<0.9.0"]
|
|
22
|
+
build-backend = "uv_build"
|
|
23
|
+
|
|
24
|
+
[dependency-groups]
|
|
25
|
+
dev = [
|
|
26
|
+
"fastapi>=0.121.3",
|
|
27
|
+
"pytest>=9.0.1",
|
|
28
|
+
"pytest-cov>=7.0.0",
|
|
29
|
+
"pytest-freezer>=0.4.9",
|
|
30
|
+
"python-multipart>=0.0.20",
|
|
31
|
+
"uvicorn>=0.38.0",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[tool.ruff]
|
|
35
|
+
line-length = 99
|
|
36
|
+
|
|
37
|
+
[tool.ruff.lint]
|
|
38
|
+
extend-select = ["I"]
|
|
39
|
+
ignore = ["E241", "F722", "E501", "E203", "F811", "F821"]
|
|
40
|
+
|
|
41
|
+
[tool.coverage.run]
|
|
42
|
+
omit = [
|
|
43
|
+
"src/easy_oauth/testing/*"
|
|
44
|
+
]
|