Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: oauth provider #549

Open
wants to merge 39 commits into
base: 0.26
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
e2f316f
fix: files for oauth2 providers
sattvikc Nov 11, 2024
59109a8
fix: interface
sattvikc Nov 12, 2024
f577523
fix: oauth2 interfaces
sattvikc Nov 12, 2024
6479bda
fix: update recipe.py
sattvikc Nov 26, 2024
94fc82d
fix: login request impl
sattvikc Nov 26, 2024
1fe7e51
fix: query params for put request
sattvikc Nov 26, 2024
8f96467
fix: consent request
sattvikc Nov 26, 2024
804121d
fix: more impl
sattvikc Nov 26, 2024
22ab47b
fix: more impl
sattvikc Nov 27, 2024
e7fe99c
Merge branch '0.26' into feat/oauth-provider
sattvikc Nov 29, 2024
e776828
fix: recipe impl
sattvikc Nov 29, 2024
f462284
fix: recipe impl
sattvikc Nov 29, 2024
c83ef6f
fix: validate_oauth2_access_token
sattvikc Nov 29, 2024
2f7e994
fix: authorization
sattvikc Nov 29, 2024
00a6128
fix: token exchange
sattvikc Nov 29, 2024
6f6b6e4
fix: frontend redirection url
sattvikc Nov 29, 2024
07868f1
fix: revoke token
sattvikc Nov 29, 2024
4386cb8
fix: end session
sattvikc Nov 29, 2024
2c06ffb
fix: api stubs
sattvikc Dec 2, 2024
eae13cc
fix: api structures and lint fixes
sattvikc Dec 2, 2024
c4c8d11
fix: remaining type fixes
sattvikc Dec 2, 2024
a1dff9d
fix: end session
sattvikc Dec 3, 2024
1e35b54
fix: api endpoints
sattvikc Dec 3, 2024
79194a4
fix: remaining apis
sattvikc Dec 4, 2024
fbee6d6
fix: remaining impl
sattvikc Dec 4, 2024
9eb33ce
fix: typing
sattvikc Dec 4, 2024
e3d1287
fix: type and lint
sattvikc Dec 10, 2024
3041401
fix: types, exposed functions and cyclic import
sattvikc Dec 11, 2024
34e96da
fix: backend sdk tests
sattvikc Dec 12, 2024
d8dd684
fix: default recipes and fixes for test
sattvikc Dec 12, 2024
fc42477
fix: tests
sattvikc Dec 12, 2024
724c97b
fix: tests
sattvikc Dec 12, 2024
dae1204
fix: tests
sattvikc Dec 12, 2024
213b9fe
fix: tests
sattvikc Dec 12, 2024
92a7a0b
fix: tests
sattvikc Dec 13, 2024
91926ae
fix: tests
sattvikc Dec 13, 2024
632b5dd
fix: openid and cookies
sattvikc Dec 17, 2024
2685c31
fix: roles and permissions for oauth2
sattvikc Dec 17, 2024
0226085
fix: auth react tests
sattvikc Dec 17, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .pylintrc
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ disable=raw-checker-failed,
no-else-raise,
too-many-nested-blocks,
broad-exception-raised,
too-many-public-methods,


# Enable the message, report, category or checker with the given id(s). You can
Expand Down
4 changes: 2 additions & 2 deletions supertokens_python/auth_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -536,7 +536,7 @@ async def check_auth_type_and_linking_status(
if session_user_result.status == "SHOULD_AUTOMATICALLY_LINK_FALSE":
if should_try_linking_with_session_user is True:
raise BadInputError(
"should_do_automatic_account_linking returned false when creating primary user but shouldTryLinkingWithSessionUser is true"
"shouldDoAutomaticAccountLinking returned false when making the session user primary but shouldTryLinkingWithSessionUser is true"
)
return OkFirstFactorResponse()
elif (
Expand Down Expand Up @@ -565,7 +565,7 @@ async def check_auth_type_and_linking_status(
if isinstance(should_link, ShouldNotAutomaticallyLink):
if should_try_linking_with_session_user is True:
raise BadInputError(
"should_do_automatic_account_linking returned false when creating primary user but shouldTryLinkingWithSessionUser is true"
"shouldDoAutomaticAccountLinking returned false when making the session user primary but shouldTryLinkingWithSessionUser is true"
)
return OkFirstFactorResponse()
else:
Expand Down
6 changes: 6 additions & 0 deletions supertokens_python/framework/django/django_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,9 @@ def set_json_content(self, content: Dict[str, Any]):
separators=(",", ":"),
).encode("utf-8")
self.response_sent = True

def redirect(self, url: str) -> BaseResponse:
if not self.response_sent:
self.set_header("Location", url)
self.set_status_code(302)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doesn't this need to set response_sent?

return self
6 changes: 6 additions & 0 deletions supertokens_python/framework/fastapi/fastapi_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,9 @@ def set_json_content(self, content: Dict[str, Any]):
self.set_header("Content-Length", str(len(body)))
self.response.body = body
self.response_sent = True

def redirect(self, url: str) -> BaseResponse:
if not self.response_sent:
self.set_header("Location", url)
self.set_status_code(302)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doesn't this need to set response_sent?

return self
7 changes: 7 additions & 0 deletions supertokens_python/framework/flask/flask_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,10 @@ def set_json_content(self, content: Dict[str, Any]):
separators=(",", ":"),
).encode("utf-8")
self.response_sent = True

def redirect(self, url: str) -> BaseResponse:
self.set_header("Location", url)
self.set_status_code(302)
self.response.data = b""
self.response_sent = True
return self
8 changes: 8 additions & 0 deletions supertokens_python/framework/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ async def json(self) -> Union[Any, None]:
async def form_data(self) -> Dict[str, Any]:
pass

async def get_json_or_form_data(self) -> Union[Dict[str, Any], None]:
content_type = self.get_header("Content-Type")
if content_type is None:
return None
if content_type.startswith("application/json"):
return await self.json()
return await self.form_data()

@abstractmethod
def method(self) -> str:
pass
Expand Down
4 changes: 4 additions & 0 deletions supertokens_python/framework/response.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,7 @@ def set_json_content(self, content: Dict[str, Any]):
@abstractmethod
def set_html_content(self, content: str):
pass

@abstractmethod
def redirect(self, url: str) -> "BaseResponse":
pass
13 changes: 9 additions & 4 deletions supertokens_python/querier.py
Original file line number Diff line number Diff line change
Expand Up @@ -402,28 +402,33 @@ async def send_put_request(
self,
path: NormalisedURLPath,
data: Union[Dict[str, Any], None],
query_params: Union[Dict[str, Any], None],
user_context: Union[Dict[str, Any], None],
) -> Dict[str, Any]:
self.invalidate_core_call_cache(user_context)
if data is None:
data = {}
if query_params is None:
query_params = {}

headers = await self.__get_headers_with_api_version(path, user_context)
headers["content-type"] = "application/json; charset=utf-8"

async def f(url: str, method: str) -> Response:
nonlocal headers, data
nonlocal headers, data, query_params
if Querier.network_interceptor is not None:
(
url,
method,
headers,
_,
query_params,
data,
) = Querier.network_interceptor( # pylint:disable=not-callable
url, method, headers, {}, data, user_context
url, method, headers, query_params, data, user_context
)
return await self.api_request(url, method, 2, headers=headers, json=data)
return await self.api_request(
url, method, 2, headers=headers, json=data, params=query_params
)

return await self.__send_request_helper(path, "PUT", f, len(self.__hosts))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,7 @@ async def update_email_or_password(
response = await self.querier.send_put_request(
NormalisedURLPath("/recipe/user"),
data,
None,
user_context=user_context,
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ async def create_or_update_tenant(
response = await self.querier.send_put_request(
NormalisedURLPath("/recipe/multitenancy/tenant/v2"),
json_body,
None,
user_context=user_context,
)
return CreateOrUpdateTenantOkResult(
Expand Down Expand Up @@ -217,6 +218,7 @@ async def create_or_update_third_party_config(
"config": config.to_json(),
"skipValidation": skip_validation is True,
},
None,
user_context=user_context,
)

Expand Down
33 changes: 33 additions & 0 deletions supertokens_python/recipe/oauth2provider/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Copyright (c) 2024, VRAI Labs and/or its affiliates. All rights reserved.
#
# This software is licensed under the Apache License, Version 2.0 (the
# "License") as published by the Apache Software Foundation.
#
# You may not use this file except in compliance with the License. You may
# obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from __future__ import annotations

from typing import TYPE_CHECKING, Callable, Union

from . import exceptions as ex
from . import recipe, utils

exceptions = ex
InputOverrideConfig = utils.InputOverrideConfig

if TYPE_CHECKING:
from supertokens_python.supertokens import AppInfo

from ...recipe_module import RecipeModule


def init(
override: Union[InputOverrideConfig, None] = None,
) -> Callable[[AppInfo], RecipeModule]:
return recipe.OAuth2ProviderRecipe.init(override)
23 changes: 23 additions & 0 deletions supertokens_python/recipe/oauth2provider/api/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Copyright (c) 2024, VRAI Labs and/or its affiliates. All rights reserved.
#
# This software is licensed under the Apache License, Version 2.0 (the
# "License") as published by the Apache Software Foundation.
#
# You may not use this file except in compliance with the License. You may
# obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.

from .auth import auth_get # type: ignore
from .end_session import end_session_get, end_session_post # type: ignore
from .introspect_token import introspect_token_post # type: ignore
from .login_info import login_info_get # type: ignore
from .login import login # type: ignore
from .logout import logout_post # type: ignore
from .revoke_token import revoke_token_post # type: ignore
from .token import token_post # type: ignore
from .user_info import user_info_get # type: ignore
103 changes: 103 additions & 0 deletions supertokens_python/recipe/oauth2provider/api/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# Copyright (c) 2024, VRAI Labs and/or its affiliates. All rights reserved.
#
# This software is licensed under the Apache License, Version 2.0 (the
# "License") as published by the Apache Software Foundation.
#
# You may not use this file except in compliance with the License. You may
# obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.

from __future__ import annotations

from http.cookies import SimpleCookie
from typing import TYPE_CHECKING, Any, Dict
from urllib.parse import parse_qsl
from dateutil import parser

from supertokens_python.recipe.session.asyncio import get_session
from supertokens_python.recipe.session.exceptions import TryRefreshTokenError
from supertokens_python.utils import send_200_response, send_non_200_response

if TYPE_CHECKING:
from ..interfaces import (
APIOptions,
APIInterface,
)


async def auth_get(
_tenant_id: str,
api_implementation: APIInterface,
api_options: APIOptions,
user_context: Dict[str, Any],
):
from ..interfaces import (
RedirectResponse,
ErrorOAuth2Response,
)

if api_implementation.disable_auth_get is True:
return None

original_url = api_options.request.get_original_url()
split_url = original_url.split("?", 1)
params = dict(parse_qsl(split_url[1], True)) if len(split_url) > 1 else {}

session = None
should_try_refresh = False
try:
session = await get_session(
api_options.request,
session_required=False,
user_context=user_context,
)
should_try_refresh = False
except Exception as error:
session = None

# should_try_refresh = False should generally not happen, but we can handle this as if the session is not present,
# because then we redirect to the frontend, which should handle the validation error
should_try_refresh = isinstance(error, TryRefreshTokenError)

response = await api_implementation.auth_get(
params=params,
cookie=api_options.request.get_header("cookie"),
session=session,
should_try_refresh=should_try_refresh,
options=api_options,
user_context=user_context,
)

if isinstance(response, RedirectResponse):
if response.cookies:
for cookie_string in response.cookies:
cookie = SimpleCookie()
cookie.load(cookie_string)
for morsel in cookie.values():
api_options.response.set_cookie(
key=morsel.key,
value=morsel.value,
domain=morsel.get("domain"),
secure=morsel.get("secure", True),
httponly=morsel.get("httponly", True),
expires=parser.parse(morsel.get("expires", "")).timestamp() * 1000, # type: ignore
path=morsel.get("path", "/"),
samesite=morsel.get("samesite", "lax"),
)
return api_options.response.redirect(response.redirect_to)
elif isinstance(response, ErrorOAuth2Response):
return send_non_200_response(
{
"error": response.error,
"error_description": response.error_description,
},
response.status_code or 400,
api_options.response,
)
else:
return send_200_response(response.to_json(), api_options.response)
Loading
Loading