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.
@@ -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
+ ]
@@ -0,0 +1,4 @@
1
+ from .cap import Capability, CapabilitySet
2
+ from .manager import OAuthManager
3
+
4
+ __all__ = ["Capability", "CapabilitySet", "OAuthManager"]