diff --git a/.github/workflows/pythonapp.yml b/.github/workflows/pythonapp.yml index e114895..c193694 100644 --- a/.github/workflows/pythonapp.yml +++ b/.github/workflows/pythonapp.yml @@ -1,4 +1,4 @@ -name: Test Application + name: Test Application on: [push, pull_request] @@ -10,9 +10,9 @@ jobs: python-version: ["3.7", "3.8", "3.9", "3.10"] name: Python ${{ matrix.python-version }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v3 with: python-version: ${{ matrix.python-version }} - name: Install dependencies @@ -31,7 +31,7 @@ jobs: runs-on: ubuntu-latest name: Lint steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python 3.8 uses: actions/setup-python@v2 with: diff --git a/.github/workflows/pythonpublish.yml b/.github/workflows/pythonpublish.yml index 368bd9d..ba42ba6 100644 --- a/.github/workflows/pythonpublish.yml +++ b/.github/workflows/pythonpublish.yml @@ -8,9 +8,9 @@ jobs: deploy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v3 with: python-version: "3.x" - name: Install dependencies diff --git a/README.md b/README.md index 0a6f301..b79967f 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,16 @@ DRIVERS = { `redirect` can be a route name or a path. +## Configuration of your OAuth app: + +Then you should create an OAuth App on your provider dashboard. Here are some links: + +- GitHub: +- GitLab: +- BitBucket (Atlassian): you must first [create a workspace](https://bitbucket.org/account/workspaces/) and then in `Settings` add an `OAuth consumer` here https://bitbucket.org/{your-workspace-slug}/workspace/settings/api +- ... + + ## Usage To authenticate users using an OAuth provider, you will need two routes: one for redirecting the user to the OAuth provider, and another for receiving the callback from the provider after authentication. @@ -150,6 +160,27 @@ Some OAuth providers support optional parameters. To include those in the redire return OAuth.driver("github").with_data({"key": "value"}) ``` +### Refresh token + +Some OAuth providers support refreshing token (GitLab, BitBucket and Google at least). For that +you need a `refresh_token` obtained when calling `user()`: + +```python +new_user = OAuth.driver("gitlab").refresh(user.refresh_token) +new_user.token #== is a new token +``` + +### Revoke token programmatically + +Some OAuth providers support revoking token programmatically. For that +you need to pass the token to the `revoke()` method: + +```python +revoked = OAuth.driver("gitlab").revoke(token) +``` + +It returned a boolean to tell if it was successful or not. + ## Contributing Please read the [Contributing Documentation](CONTRIBUTING.md) here. diff --git a/requirements.txt b/requirements.txt index 468dea0..3b7f296 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,2 @@ masonite>=4.0<5.0 masonite-orm -requests-oauthlib \ No newline at end of file diff --git a/setup.py b/setup.py index 0e4b165..6f9f441 100644 --- a/setup.py +++ b/setup.py @@ -8,7 +8,7 @@ # Versions should comply with PEP440. For a discussion on single-sourcing # the version across setup.py and the project code, see # https://packaging.python.org/en/latest/single_source_version.html - version="1.1.3", + version="1.2.0", packages=[ "masonite.oauth", "masonite.oauth.config", @@ -63,7 +63,7 @@ # your project is installed. For an analysis of "install_requires" vs pip's # requirements files see: # https://packaging.python.org/en/latest/requirements.html - install_requires=["masonite>=4.0<5.0", "requests-oauthlib"], + install_requires=["masonite>=4.0<5.0"], # List additional groups of dependencies here (e.g. development # dependencies). You can install these using the following syntax, # for example: diff --git a/src/masonite/oauth/OAuth.py b/src/masonite/oauth/OAuth.py index e29acd4..fbf1db8 100644 --- a/src/masonite/oauth/OAuth.py +++ b/src/masonite/oauth/OAuth.py @@ -12,7 +12,11 @@ def set_configuration(self, config): return self def driver(self, name): - return self.drivers[name].set_options(self.get_config_options(name)) + try: + selected_driver = self.drivers[name] + except KeyError: + raise Exception(f"The driver {name} has not been registered as an OAuth driver.") + return selected_driver.set_options(self.get_config_options(name)) def get_config_options(self, driver=None): if driver is None: diff --git a/src/masonite/oauth/OAuthFacade.pyi b/src/masonite/oauth/OAuthFacade.pyi new file mode 100644 index 0000000..a18ad31 --- /dev/null +++ b/src/masonite/oauth/OAuthFacade.pyi @@ -0,0 +1,18 @@ +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .drivers.BaseDriver import BaseDriver + +class OAuth: + def add_driver(name: str, driver: "BaseDriver"): + """Register a new driver for OAuth2.""" + ... + def set_configuration(config: dict) -> OAuth: + """Set configuration for OAuth2.""" + ... + def driver(name: str) -> "BaseDriver": + """Get OAuth2 instance for given driver.""" + ... + def get_config_options(driver: str = None) -> dict: + """Get configuration options for a given driver or the default driver.""" + ... diff --git a/src/masonite/oauth/OAuthUser.py b/src/masonite/oauth/OAuthUser.py index 4b4a90e..91f3522 100644 --- a/src/masonite/oauth/OAuthUser.py +++ b/src/masonite/oauth/OAuthUser.py @@ -6,15 +6,27 @@ def __init__(self): self.email = None self.avatar = None self.token = None + self.refresh_token = None + self.expires_in = None def set_token(self, token): self.token = token return self + def set_refresh_token(self, token): + self.refresh_token = token + return self + + def set_expires_in(self, expires_in): + self.expires_in = expires_in + return self + def build(self, raw_data): self.id = raw_data.get("id", None) self.name = raw_data.get("name", None) self.nickname = raw_data.get("nickname", None) self.email = raw_data.get("email", None) self.avatar = raw_data.get("avatar", None) + self.refresh_token = raw_data.get("refresh_token", None) + self.expires_in = raw_data.get("expires_in", None) return self diff --git a/src/masonite/oauth/drivers/AppleDriver.py b/src/masonite/oauth/drivers/AppleDriver.py index 9406fe4..e717e7a 100644 --- a/src/masonite/oauth/drivers/AppleDriver.py +++ b/src/masonite/oauth/drivers/AppleDriver.py @@ -1,5 +1,4 @@ from .BaseDriver import BaseDriver -from ..OAuthUser import OAuthUser class AppleDriver(BaseDriver): @@ -12,36 +11,11 @@ def get_auth_url(self): def get_token_url(self): return "https://appleid.apple.com/auth/token" - def user(self): - user_data, token = super().user() - user = ( - OAuthUser() - .set_token(token) - .build( - { - "id": user_data["sub"], - "nickname": user_data["nickname"], - "name": user_data["name"], - "email": user_data["email"], - "avatar": user_data["picture"], - } - ) - ) - return user - - def user_from_token(self, token): - user_data = super().user_from_token(token) - user = ( - OAuthUser() - .set_token(token) - .build( - { - "id": user_data["sub"], - "nickname": user_data["nickname"], - "name": user_data["name"], - "email": user_data["email"], - "avatar": user_data["picture"], - } - ) - ) - return user + def map_user_data(self, data): + return { + "id": data["sub"], + "nickname": data["nickname"], + "name": data["name"], + "email": data["email"], + "avatar": data["picture"], + } diff --git a/src/masonite/oauth/drivers/BaseDriver.py b/src/masonite/oauth/drivers/BaseDriver.py index 00e93c3..78bdc38 100644 --- a/src/masonite/oauth/drivers/BaseDriver.py +++ b/src/masonite/oauth/drivers/BaseDriver.py @@ -1,9 +1,11 @@ import requests -import json from urllib.parse import urlencode -from requests_oauthlib import OAuth2Session + from masonite.exceptions import RouteNotFoundException from masonite.facades import Url +from masonite.utils.str import random_string + +from ..OAuthUser import OAuthUser class BaseDriver: @@ -14,6 +16,7 @@ def __init__(self, application): self._is_stateless = False self._scopes = self.get_default_scopes() self._data = {} + self._scope_separator = "," def set_options(self, options): self.options = options @@ -30,38 +33,69 @@ def get_absolute_redirect_uri(self): redirect_url = redirect_route_or_url return Url.url(redirect_url) - def get_client(self): - self._scopes.sort() - return OAuth2Session( - client_id=self.options.get("client_id"), - redirect_uri=self.get_absolute_redirect_uri(), - scope=self._scopes, - ) + def get_auth_params(self, state=None): + params = { + "client_id": self.options.get("client_id"), + "redirect_uri": self.get_absolute_redirect_uri(), + "scope": self.format_scopes(), + "response_type": "code", + } + if self.use_state(): + params["state"] = state + # add custom params + params.update(self._data) + + return params + + def build_auth_url(self, state): + params = self.get_auth_params(state) + base_url = self.get_auth_url() + query = urlencode(params) + return f"{base_url}?{query}" + + def redirect(self, state=None): + if self.use_state(): + # let user provide their own generated state + state = state or self.get_state() + # self.application.make("session").set("state", state) + self.application.make("request").session.set("state", state) + else: + state = None + authorization_url = self.build_auth_url(state) - def redirect(self): - client = self.get_client() - authorization_url, state = client.authorization_url(self.get_auth_url()) - # add optional parameters - authorization_url += "&" + urlencode(self._data) - # self.application.make("session").set("state", state) - self.application.make("request").session.set("state", state) return self.application.make("response").redirect(location=authorization_url) + def get_token_fields(self, code): + fields = { + "grant_type": "authorization_code", + "client_id": self.options.get("client_id"), + "client_secret": self.options.get("client_secret"), + "code": code, + "redirect_uri": self.get_absolute_redirect_uri(), + } + return fields + def get_token(self): code = self.application.make("request").input("code") - client = self.get_client() - response = client.fetch_token( - self.get_token_url(), - client_secret=self.options.get("client_secret"), - code=code, + data = self.get_token_fields(code) + response = requests.post( + self.get_token_url(), data, headers={"Accept": "application/json"} ) - token = response.get("access_token") - return client, token + data = response.json() + if response.status_code != 200: + raise Exception("error") + return data def stateless(self): self._is_stateless = True return self + def use_state(self): + return not self._is_stateless + + def get_state(self): + return random_string(40) + def scopes(self, scopes_list): self._scopes.extend(scopes_list) return self @@ -73,6 +107,13 @@ def set_scopes(self, scopes_list): def has_scope(self, scope): return scope in self._scopes + def format_scopes(self): + self._scopes = list(set(self._scopes)) + # order list of scopes alphabetically + self._scopes.sort() + # + return self._scope_separator.join(self._scopes) + def with_data(self, data): """Add optional parameters in the redirect request that the provider might support. data should be a dict that will be converted into URL GET params.""" @@ -82,11 +123,10 @@ def with_data(self, data): def has_invalid_state(self): if self._is_stateless: return False - # state = self.application.make("session").get("state") state = self.application.make("request").session.get("state") return state != self.application.make("request").input("state") - def get_default_scopes(self): + def get_default_scopes(self) -> list: return [] def reset_scopes(self): @@ -98,6 +138,12 @@ def get_auth_url(self): def get_token_url(self): raise NotImplementedError() + def get_refresh_token_url(self): + return self.get_token_url() + + def get_revoke_token_url(self): + raise NotImplementedError() + def get_user_url(self): raise NotImplementedError() @@ -107,22 +153,79 @@ def get_email_url(self): def get_request_options(self, *args): raise NotImplementedError() + def map_user_data(self, data) -> "OAuthUser": + raise NotImplementedError() + + def revoke(self, token) -> bool: + raise NotImplementedError() + def get_email_by_token(self, token): """E-mail is often not send directly with user info, a subsequent request needs to be made in order to fetch user e-mail (if scopes allow it).""" - response = requests.get(self.get_email_url(), **self.get_request_options(token)) - email_data = json.loads(response.content.decode("utf-8")) - return email_data - - def user(self): - # if self.has_invalid_state(): - # raise Exception("Invalid state") - client, token = self.get_token() - response = client.get(self.get_user_url()) - user_data = json.loads(response.content.decode("utf-8")) - return user_data, token - - def user_from_token(self, token): + if self.get_email_url(): + response = requests.get(self.get_email_url(), **self.get_request_options(token)) + return response.json() + else: + return {} + + def user(self) -> "OAuthUser": + if self.has_invalid_state(): + raise Exception("Invalid state") + data = self.get_token() + token = data.get("access_token") + user = self.user_from_token(token) + user.set_refresh_token(data.get("refresh_token")).set_expires_in(data.get("expires_in")) + return user + + def user_from_token(self, token: str) -> "OAuthUser": response = requests.get(self.get_user_url(), **self.get_request_options(token)) - user_data = json.loads(response.content.decode("utf-8")) - return user_data + data = response.json() + if response.status_code != 200: + raise Exception("Provider API error") + email = self.get_email_by_token(token) + if email: + data.update({"email": email}) + user = OAuthUser().build(self.map_user_data(data)) + user.set_token(token) + return user + + def refresh(self, refresh_token: str) -> "OAuthUser": + params = { + "client_id": self.options.get("client_id"), + "client_secret": self.options.get("client_secret"), + "refresh_token": refresh_token, + "grant_type": "refresh_token", + "redirect_uri": self.get_absolute_redirect_uri(), + } + query = urlencode(params) + url = f"{self.get_refresh_token_url()}?{query}" + response = requests.post(url, headers={"Accept": "application/json"}) + if response.status_code != 200: + raise Exception("Provider API error") + data = response.json() + token = data.get("access_token") + user = self.user_from_token(token) + user.set_refresh_token(data.get("refresh_token")).set_expires_in(data.get("expires_in")) + return user + + def perform_request(self, token, method, url, **options): + """Perform an authenticated request to the API on behalf on the user to which the given + token belongs with the token as a 'Bearer' authentication token.""" + request_options = { + **self.get_request_options(token), + **options, + } + response = requests.request(method, url, **request_options) + return response + + def perform_basic_request(self, method, url, **options): + """Perform an authenticated request to the API on behalf on the user to which the given + token belongs with Basic HTTP authentication.""" + request_options = { + "auth": requests.auth.HTTPBasicAuth( + self.options.get("client_id"), self.options.get("client_secret") + ), + **options, + } + response = requests.request(method, url, **request_options) + return response diff --git a/src/masonite/oauth/drivers/BitbucketDriver.py b/src/masonite/oauth/drivers/BitbucketDriver.py index e4551c4..e1332f8 100644 --- a/src/masonite/oauth/drivers/BitbucketDriver.py +++ b/src/masonite/oauth/drivers/BitbucketDriver.py @@ -1,8 +1,13 @@ from .BaseDriver import BaseDriver -from ..OAuthUser import OAuthUser class BitbucketDriver(BaseDriver): + """ + doc + https://confluence.atlassian.com/bitbucketserver/bitbucket-oauth-2-0-provider-api-1108483661.html + https://developer.atlassian.com/cloud/bitbucket/oauth-2/ + """ + def get_default_scopes(self): # https://developer.atlassian.com/cloud/bitbucket/bitbucket-cloud-rest-api-scopes/ return ["email"] @@ -22,24 +27,6 @@ def get_email_url(self): def get_request_options(self, token): return {"headers": {"Authorization": f"Bearer {token}"}} - def user(self): - user_data, token = super().user() - email = self.get_email_by_token(token) - user = ( - OAuthUser() - .set_token(token) - .build( - { - "id": user_data["uuid"], - "nickname": user_data["username"], - "name": user_data["display_name"], - "email": user_data.get("email", "") or email, - "avatar": user_data["links"]["avatar"]["href"], - } - ) - ) - return user - def get_email_by_token(self, token): email = "" if self.has_scope("email"): @@ -53,21 +40,23 @@ def get_email_by_token(self, token): email = email_data["email"] return email - def user_from_token(self, token): - user_data = super().user_from_token(token) - # fetch email if possible - email = self.get_email_by_token(token) - user = ( - OAuthUser() - .set_token(token) - .build( - { - "id": user_data["uuid"], - "nickname": user_data["username"], - "name": user_data["display_name"], - "email": user_data.get("email", "") or email, - "avatar": user_data["links"]["avatar"]["href"], - } - ) + def map_user_data(self, data): + return { + "id": data["uuid"], + "nickname": data["username"], + "name": data["display_name"], + "email": data.get("email", ""), + "avatar": data["links"]["avatar"]["href"], + } + + def revoke(self, token): + response = self.perform_basic_request( + "post", + "https://bitbucket.org/site/oauth2/revoke", + json={"token": token}, + headers={"Accept": "application/json"}, ) - return user + if response.status_code == 200: + return True + else: + return False diff --git a/src/masonite/oauth/drivers/FacebookDriver.py b/src/masonite/oauth/drivers/FacebookDriver.py index d3b76c1..c144e7c 100644 --- a/src/masonite/oauth/drivers/FacebookDriver.py +++ b/src/masonite/oauth/drivers/FacebookDriver.py @@ -1,5 +1,4 @@ from .BaseDriver import BaseDriver -from ..OAuthUser import OAuthUser class FacebookDriver(BaseDriver): @@ -21,36 +20,14 @@ def get_request_options(self, token): "query": {"prettyPrint": "false"}, } - def user(self): - user_data, token = super().user() - user = ( - OAuthUser() - .set_token(token) - .build( - { - "id": user_data["sub"], - "nickname": user_data["nickname"], - "name": user_data["name"], - "email": user_data["email"], - "avatar": user_data["picture"], - } - ) - ) - return user - - def user_from_token(self, token): - user_data = super().user_from_token(token) - user = ( - OAuthUser() - .set_token(token) - .build( - { - "id": user_data["sub"], - "nickname": user_data["nickname"], - "name": user_data["name"], - "email": user_data["email"], - "avatar": user_data["picture"], - } - ) - ) - return user + def map_user_data(self, data): + return { + "id": data["sub"], + "nickname": data["nickname"], + "name": data["name"], + "email": data["email"], + "avatar": data["picture"], + } + + def revoke(self, token) -> bool: + return diff --git a/src/masonite/oauth/drivers/GithubDriver.py b/src/masonite/oauth/drivers/GithubDriver.py index 04f0e24..b016340 100644 --- a/src/masonite/oauth/drivers/GithubDriver.py +++ b/src/masonite/oauth/drivers/GithubDriver.py @@ -37,40 +37,47 @@ def get_email_by_token(self, token): break return email - def user(self): - user_data, token = super().user() - # fetch email if possible - email = self.get_email_by_token(token) - user = ( - OAuthUser() - .set_token(token) - .build( - { - "id": user_data["id"], - "nickname": user_data["login"], - "name": user_data["name"], - "email": user_data["email"] or email, - "avatar": user_data["avatar_url"], - } - ) + def map_user_data(self, data): + return { + "id": data["id"], + "nickname": data["login"], + "name": data["name"], + "email": data["email"], + "avatar": data["avatar_url"], + } + + def revoke(self, token): + # https://docs.github.com/en/rest/reference/apps#delete-an-app-token" + response = self.perform_basic_request( + "delete", + f"https://api.github.com/applications/{self.options.get('client_id')}/grant", + json={"access_token": token}, + headers={"Accept": "application/vnd.github.v3+json"}, ) - return user + if response.status_code == 204: + return True + else: + return False + + def user(self) -> "OAuthUser": + """ + GitHub Oauth2 mechanism does not provide refresh token info from OAuth flow, a subsequent + request needs to be done. + https://docs.github.com/en/rest/reference/apps#check-a-token + """ + + if self.has_invalid_state(): + raise Exception("Invalid state") + data = self.get_token() + token = data.get("access_token") + user = self.user_from_token(token) - def user_from_token(self, token): - user_data = super().user_from_token(token) - # fetch email if possible - email = self.get_email_by_token(token) - user = ( - OAuthUser() - .set_token(token) - .build( - { - "id": user_data["id"], - "nickname": user_data["login"], - "name": user_data["name"], - "email": user_data["email"] or email, - "avatar": user_data["avatar_url"], - } - ) + response = self.perform_basic_request( + "post", + f"https://api.github.com/applications/{self.options.get('client_id')}/token", + json={"access_token": token}, + headers={"Accept": "application/vnd.github.v3+json"}, ) + full_data = response.json() + user.set_expires_in(full_data.get("expires_at")) return user diff --git a/src/masonite/oauth/drivers/GitlabDriver.py b/src/masonite/oauth/drivers/GitlabDriver.py index 11d92c6..5e28efc 100644 --- a/src/masonite/oauth/drivers/GitlabDriver.py +++ b/src/masonite/oauth/drivers/GitlabDriver.py @@ -1,5 +1,4 @@ from .BaseDriver import BaseDriver -from ..OAuthUser import OAuthUser class GitlabDriver(BaseDriver): @@ -12,42 +11,33 @@ def get_auth_url(self): def get_token_url(self): return "https://gitlab.com/oauth/token" + def get_email_url(self): + return "" + def get_user_url(self): return "https://gitlab.com/api/v4/user" def get_request_options(self, token): return {"headers": {"Authorization": f"Bearer {token}"}} - def user(self): - user_data, token = super().user() - user = ( - OAuthUser() - .set_token(token) - .build( - { - "id": user_data["id"], - "nickname": user_data["username"], - "name": user_data["name"], - "email": user_data["email"], - "avatar": user_data["avatar_url"], - } - ) - ) - return user - - def user_from_token(self, token): - user_data = super().user_from_token(token) - user = ( - OAuthUser() - .set_token(token) - .build( - { - "id": user_data["id"], - "nickname": user_data["username"], - "name": user_data["name"], - "email": user_data["email"], - "avatar": user_data["avatar_url"], - } - ) + def map_user_data(self, data): + return { + "id": data["id"], + "nickname": data["username"], + "name": data["name"], + "email": data["email"], + "avatar": data["avatar_url"], + } + + def revoke(self, token): + # https://docs.gitlab.com/ee/api/oauth2.html#revoke-a-token + response = self.perform_basic_request( + "post", + "https://gitlab.com/oauth/revoke", + json={"token": token}, + headers={"Accept": "application/json"}, ) - return user + if response.status_code == 200: + return True + else: + return False diff --git a/src/masonite/oauth/drivers/GoogleDriver.py b/src/masonite/oauth/drivers/GoogleDriver.py index 3a1e444..2387d50 100644 --- a/src/masonite/oauth/drivers/GoogleDriver.py +++ b/src/masonite/oauth/drivers/GoogleDriver.py @@ -1,5 +1,6 @@ +import requests + from .BaseDriver import BaseDriver -from ..OAuthUser import OAuthUser class GoogleDriver(BaseDriver): @@ -21,36 +22,22 @@ def get_request_options(self, token): "query": {"prettyPrint": "false"}, } - def user(self): - user_data, token = super().user() - user = ( - OAuthUser() - .set_token(token) - .build( - { - "id": user_data["sub"], - "nickname": user_data["nickname"], - "name": user_data["name"], - "email": user_data["email"], - "avatar": user_data["picture"], - } - ) - ) - return user - - def user_from_token(self, token): - user_data = super().user_from_token(token) - user = ( - OAuthUser() - .set_token(token) - .build( - { - "id": user_data["sub"], - "nickname": user_data["nickname"], - "name": user_data["name"], - "email": user_data["email"], - "avatar": user_data["picture"], - } - ) + def map_user_data(self, data): + return { + "id": data["sub"], + "nickname": data["nickname"], + "name": data["name"], + "email": data["email"], + "avatar": data["picture"], + } + + def revoke(self, token): + # https://developers.google.com/identity/protocols/oauth2/javascript-implicit-flow#oauth-2.0-endpoints_6 + response = requests.post( + f"https://oauth2.googleapis.com/revoke?token={token}", + headers={"Content-Type": "application/x-www-form-urlencoded"}, ) - return user + if response.status_code == 200: + return True + else: + return False diff --git a/tests/integrations/Kernel.py b/tests/integrations/Kernel.py index 7204c17..49485c1 100644 --- a/tests/integrations/Kernel.py +++ b/tests/integrations/Kernel.py @@ -58,6 +58,7 @@ def register_configurations(self): self.application.bind("notifications.location", "tests/integrations/notifications") self.application.bind("events.location", "tests/integrations/events") self.application.bind("tasks.location", "tests/integrations/tasks") + self.application.bind("models.location", "tests/integrations/app") self.application.bind("server.runner", "masonite.commands.ServeCommand.main") diff --git a/tests/unit/test_providers.py b/tests/unit/test_providers.py index 7502187..c2daa34 100644 --- a/tests/unit/test_providers.py +++ b/tests/unit/test_providers.py @@ -1,4 +1,3 @@ -import pytest import requests from urllib.parse import urlparse, parse_qs from masonite.tests import TestCase @@ -80,7 +79,6 @@ def test_bitbucket(self): assert redirect_uri == "http://localhost:8000/auth/callback/bitbucket" response.assertSessionHas("state", state) - @pytest.mark.skip("Facebook redirect url has a temporary error...") def test_facebook(self): response = self.get("/auth/redirect/facebook").assertRedirect() redirect_url = response.response.header("Location")