Skip to content

Commit 9ab912b

Browse files
grdsdevclaude
andauthored
feat(auth): add OAuth 2.1 client admin endpoints (#1240)
Co-authored-by: Claude <noreply@anthropic.com>
1 parent ce4381a commit 9ab912b

File tree

12 files changed

+928
-62
lines changed

12 files changed

+928
-62
lines changed

src/auth/infra/docker-compose.yml

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
name: auth-tests
33
services:
44
gotrue: # Signup enabled, autoconfirm off
5-
image: supabase/auth:v2.178.0
5+
image: supabase/auth:v2.180.0
66
ports:
77
- '9999:9999'
88
environment:
@@ -43,7 +43,7 @@ services:
4343
- db
4444
restart: on-failure
4545
autoconfirm: # Signup enabled, autoconfirm on
46-
image: supabase/auth:v2.178.0
46+
image: supabase/auth:v2.180.0
4747
ports:
4848
- '9998:9998'
4949
environment:
@@ -70,11 +70,13 @@ services:
7070
GOTRUE_SMTP_PASS: GOTRUE_SMTP_PASS
7171
GOTRUE_SMTP_ADMIN_EMAIL: admin@email.com
7272
GOTRUE_COOKIE_KEY: 'sb'
73+
GOTRUE_OAUTH_SERVER_ENABLED: 'true'
74+
GOTRUE_OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION: 'true'
7375
depends_on:
7476
- db
7577
restart: on-failure
7678
autoconfirm_with_asymmetric_keys: # Signup enabled, autoconfirm on
77-
image: supabase/auth:v2.169.0
79+
image: supabase/auth:v2.180.0
7880
ports:
7981
- '9996:9996'
8082
environment:
@@ -105,7 +107,7 @@ services:
105107
- db
106108
restart: on-failure
107109
disabled: # Signup disabled
108-
image: supabase/auth:v2.178.0
110+
image: supabase/auth:v2.180.0
109111
ports:
110112
- '9997:9997'
111113
environment:

src/auth/src/supabase_auth/_async/gotrue_admin_api.py

Lines changed: 166 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from pydantic import TypeAdapter
77

88
from ..helpers import (
9-
is_valid_uuid,
9+
validate_uuid,
1010
model_validate,
1111
parse_link_response,
1212
parse_user_response,
@@ -18,15 +18,22 @@
1818
AuthMFAAdminDeleteFactorResponse,
1919
AuthMFAAdminListFactorsParams,
2020
AuthMFAAdminListFactorsResponse,
21+
CreateOAuthClientParams,
2122
GenerateLinkParams,
2223
GenerateLinkResponse,
2324
InviteUserByEmailOptions,
25+
OAuthClient,
26+
OAuthClientListResponse,
27+
OAuthClientResponse,
28+
PageParams,
2429
SignOutScope,
30+
UpdateOAuthClientParams,
2531
User,
2632
UserList,
2733
UserResponse,
2834
)
2935
from .gotrue_admin_mfa_api import AsyncGoTrueAdminMFAAPI
36+
from .gotrue_admin_oauth_api import AsyncGoTrueAdminOAuthAPI
3037
from .gotrue_base_api import AsyncGoTrueBaseAPI
3138

3239

@@ -50,8 +57,15 @@ def __init__(
5057
)
5158
# TODO(@o-santi): why is is this done this way?
5259
self.mfa = AsyncGoTrueAdminMFAAPI()
53-
self.mfa.list_factors = self._list_factors # type: ignore
54-
self.mfa.delete_factor = self._delete_factor # type: ignore
60+
self.mfa.list_factors = self._list_factors # type: ignore
61+
self.mfa.delete_factor = self._delete_factor # type: ignore
62+
self.oauth = AsyncGoTrueAdminOAuthAPI()
63+
self.oauth.list_clients = self._list_oauth_clients # type: ignore
64+
self.oauth.create_client = self._create_oauth_client # type: ignore
65+
self.oauth.get_client = self._get_oauth_client # type: ignore
66+
self.oauth.update_client = self._update_oauth_client # type: ignore
67+
self.oauth.delete_client = self._delete_oauth_client # type: ignore
68+
self.oauth.regenerate_client_secret = self._regenerate_oauth_client_secret # type: ignore
5569

5670
async def sign_out(self, jwt: str, scope: SignOutScope = "global") -> None:
5771
"""
@@ -139,7 +153,7 @@ async def get_user_by_id(self, uid: str) -> UserResponse:
139153
This function should only be called on a server.
140154
Never expose your `service_role` key in the browser.
141155
"""
142-
self._validate_uuid(uid)
156+
validate_uuid(uid)
143157

144158
response = await self._request(
145159
"GET",
@@ -158,7 +172,7 @@ async def update_user_by_id(
158172
This function should only be called on a server.
159173
Never expose your `service_role` key in the browser.
160174
"""
161-
self._validate_uuid(uid)
175+
validate_uuid(uid)
162176
response = await self._request(
163177
"PUT",
164178
f"admin/users/{uid}",
@@ -173,15 +187,15 @@ async def delete_user(self, id: str, should_soft_delete: bool = False) -> None:
173187
This function should only be called on a server.
174188
Never expose your `service_role` key in the browser.
175189
"""
176-
self._validate_uuid(id)
190+
validate_uuid(id)
177191
body = {"should_soft_delete": should_soft_delete}
178192
await self._request("DELETE", f"admin/users/{id}", body=body)
179193

180194
async def _list_factors(
181195
self,
182196
params: AuthMFAAdminListFactorsParams,
183197
) -> AuthMFAAdminListFactorsResponse:
184-
self._validate_uuid(params.get("user_id"))
198+
validate_uuid(params.get("user_id"))
185199
response = await self._request(
186200
"GET",
187201
f"admin/users/{params.get('user_id')}/factors",
@@ -192,16 +206,154 @@ async def _delete_factor(
192206
self,
193207
params: AuthMFAAdminDeleteFactorParams,
194208
) -> AuthMFAAdminDeleteFactorResponse:
195-
self._validate_uuid(params.get("user_id"))
196-
self._validate_uuid(params.get("id"))
209+
validate_uuid(params.get("user_id"))
210+
validate_uuid(params.get("id"))
197211
response = await self._request(
198212
"DELETE",
199213
f"admin/users/{params.get('user_id')}/factors/{params.get('id')}",
200214
)
201215
return model_validate(AuthMFAAdminDeleteFactorResponse, response.content)
202216

203-
def _validate_uuid(self, id: str | None) -> None:
204-
if id is None:
205-
raise ValueError("Invalid id, id cannot be none")
206-
if not is_valid_uuid(id):
207-
raise ValueError(f"Invalid id, '{id}' is not a valid uuid")
217+
async def _list_oauth_clients(
218+
self,
219+
params: PageParams | None = None,
220+
) -> OAuthClientListResponse:
221+
"""
222+
Lists all OAuth clients with optional pagination.
223+
Only relevant when the OAuth 2.1 server is enabled in Supabase Auth.
224+
225+
This function should only be called on a server.
226+
Never expose your `service_role` key in the browser.
227+
"""
228+
if params:
229+
query = QueryParams(page=params.page, per_page=params.per_page)
230+
else:
231+
query = None
232+
response = await self._request(
233+
"GET",
234+
"admin/oauth/clients",
235+
query=query,
236+
no_resolve_json=True,
237+
)
238+
239+
result = model_validate(OAuthClientListResponse, response.content)
240+
241+
# Parse pagination headers
242+
total = response.headers.get("x-total-count")
243+
if total:
244+
result.total = int(total)
245+
246+
links = response.headers.get("link")
247+
if links:
248+
for link in links.split(","):
249+
parts = link.split(";")
250+
if len(parts) >= 2:
251+
page_match = parts[0].split("page=")
252+
if len(page_match) >= 2:
253+
page_num = int(page_match[1].split("&")[0].rstrip(">"))
254+
rel = parts[1].split("=")[1].strip('"')
255+
if rel == "next":
256+
result.next_page = page_num
257+
elif rel == "last":
258+
result.last_page = page_num
259+
260+
return result
261+
262+
async def _create_oauth_client(
263+
self,
264+
params: CreateOAuthClientParams,
265+
) -> OAuthClientResponse:
266+
"""
267+
Creates a new OAuth client.
268+
Only relevant when the OAuth 2.1 server is enabled in Supabase Auth.
269+
270+
This function should only be called on a server.
271+
Never expose your `service_role` key in the browser.
272+
"""
273+
response = await self._request(
274+
"POST",
275+
"admin/oauth/clients",
276+
body=params,
277+
)
278+
279+
return OAuthClientResponse(
280+
client=model_validate(OAuthClient, response.content)
281+
)
282+
async def _get_oauth_client(
283+
self,
284+
client_id: str,
285+
) -> OAuthClientResponse:
286+
"""
287+
Gets details of a specific OAuth client.
288+
Only relevant when the OAuth 2.1 server is enabled in Supabase Auth.
289+
290+
This function should only be called on a server.
291+
Never expose your `service_role` key in the browser.
292+
"""
293+
validate_uuid(client_id)
294+
response = await self._request(
295+
"GET",
296+
f"admin/oauth/clients/{client_id}",
297+
)
298+
return OAuthClientResponse(
299+
client=model_validate(OAuthClient, response.content)
300+
)
301+
302+
async def _update_oauth_client(
303+
self,
304+
client_id: str,
305+
params: UpdateOAuthClientParams,
306+
) -> OAuthClientResponse:
307+
"""
308+
Updates an OAuth client.
309+
Only relevant when the OAuth 2.1 server is enabled in Supabase Auth.
310+
311+
This function should only be called on a server.
312+
Never expose your `service_role` key in the browser.
313+
"""
314+
validate_uuid(client_id)
315+
response = await self._request(
316+
"PUT",
317+
f"admin/oauth/clients/{client_id}",
318+
body=params,
319+
)
320+
return OAuthClientResponse(
321+
client=model_validate(OAuthClient, response.content)
322+
)
323+
324+
async def _delete_oauth_client(
325+
self,
326+
client_id: str,
327+
) -> None:
328+
"""
329+
Deletes an OAuth client.
330+
Only relevant when the OAuth 2.1 server is enabled in Supabase Auth.
331+
332+
This function should only be called on a server.
333+
Never expose your `service_role` key in the browser.
334+
"""
335+
validate_uuid(client_id)
336+
await self._request(
337+
"DELETE",
338+
f"admin/oauth/clients/{client_id}",
339+
)
340+
341+
async def _regenerate_oauth_client_secret(
342+
self,
343+
client_id: str,
344+
) -> OAuthClientResponse:
345+
"""
346+
Regenerates the secret for an OAuth client.
347+
Only relevant when the OAuth 2.1 server is enabled in Supabase Auth.
348+
349+
This function should only be called on a server.
350+
Never expose your `service_role` key in the browser.
351+
"""
352+
validate_uuid(client_id)
353+
response = await self._request(
354+
"POST",
355+
f"admin/oauth/clients/{client_id}/regenerate_secret",
356+
)
357+
return OAuthClientResponse(
358+
client=model_validate(OAuthClient, response.content)
359+
)
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
from ..types import (
2+
CreateOAuthClientParams,
3+
OAuthClientListResponse,
4+
OAuthClientResponse,
5+
PageParams,
6+
UpdateOAuthClientParams,
7+
)
8+
from typing import Optional
9+
10+
11+
class AsyncGoTrueAdminOAuthAPI:
12+
"""
13+
Contains all OAuth client administration methods.
14+
Only relevant when the OAuth 2.1 server is enabled in Supabase Auth.
15+
"""
16+
17+
async def list_clients(
18+
self,
19+
params: Optional[PageParams] = None,
20+
) -> OAuthClientListResponse:
21+
"""
22+
Lists all OAuth clients with optional pagination.
23+
Only relevant when the OAuth 2.1 server is enabled in Supabase Auth.
24+
25+
This function should only be called on a server.
26+
Never expose your `service_role` key in the browser.
27+
"""
28+
raise NotImplementedError() # pragma: no cover
29+
30+
async def create_client(
31+
self,
32+
params: CreateOAuthClientParams,
33+
) -> OAuthClientResponse:
34+
"""
35+
Creates a new OAuth client.
36+
Only relevant when the OAuth 2.1 server is enabled in Supabase Auth.
37+
38+
This function should only be called on a server.
39+
Never expose your `service_role` key in the browser.
40+
"""
41+
raise NotImplementedError() # pragma: no cover
42+
43+
async def get_client(
44+
self,
45+
client_id: str,
46+
) -> OAuthClientResponse:
47+
"""
48+
Gets details of a specific OAuth client.
49+
Only relevant when the OAuth 2.1 server is enabled in Supabase Auth.
50+
51+
This function should only be called on a server.
52+
Never expose your `service_role` key in the browser.
53+
"""
54+
raise NotImplementedError() # pragma: no cover
55+
56+
async def update_client(
57+
self,
58+
client_id: str,
59+
params: UpdateOAuthClientParams,
60+
) -> OAuthClientResponse:
61+
"""
62+
Updates an OAuth client.
63+
Only relevant when the OAuth 2.1 server is enabled in Supabase Auth.
64+
65+
This function should only be called on a server.
66+
Never expose your `service_role` key in the browser.
67+
"""
68+
raise NotImplementedError() # pragma: no cover
69+
70+
async def delete_client(
71+
self,
72+
client_id: str,
73+
) -> OAuthClientResponse:
74+
"""
75+
Deletes an OAuth client.
76+
Only relevant when the OAuth 2.1 server is enabled in Supabase Auth.
77+
78+
This function should only be called on a server.
79+
Never expose your `service_role` key in the browser.
80+
"""
81+
raise NotImplementedError() # pragma: no cover
82+
83+
async def regenerate_client_secret(
84+
self,
85+
client_id: str,
86+
) -> OAuthClientResponse:
87+
"""
88+
Regenerates the secret for an OAuth client.
89+
Only relevant when the OAuth 2.1 server is enabled in Supabase Auth.
90+
91+
This function should only be called on a server.
92+
Never expose your `service_role` key in the browser.
93+
"""
94+
raise NotImplementedError() # pragma: no cover

0 commit comments

Comments
 (0)