From d118f5eee30f67535942f35cb2e2b89551401ed7 Mon Sep 17 00:00:00 2001 From: Samuel Girardin Date: Wed, 23 Mar 2022 10:07:13 +0100 Subject: [PATCH 1/4] bump github actions dependencies --- .github/workflows/pythonapp.yml | 8 ++++---- .github/workflows/pythonpublish.yml | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) 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 From 6e75ca9a05a9ad02f17c973e733426c54f2c61a3 Mon Sep 17 00:00:00 2001 From: Samuel Girardin Date: Wed, 23 Mar 2022 11:00:58 +0100 Subject: [PATCH 2/4] refactor driver to avoid using a dependency --- src/masonite/oauth/OAuthFacade.pyi | 23 ++++ src/masonite/oauth/drivers/BaseDriver.py | 123 +++++++++++++++------ src/masonite/oauth/drivers/GithubDriver.py | 41 ++----- 3 files changed, 119 insertions(+), 68 deletions(-) create mode 100644 src/masonite/oauth/OAuthFacade.pyi diff --git a/src/masonite/oauth/OAuthFacade.pyi b/src/masonite/oauth/OAuthFacade.pyi new file mode 100644 index 0000000..bdf0c1a --- /dev/null +++ b/src/masonite/oauth/OAuthFacade.pyi @@ -0,0 +1,23 @@ +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .drivers.BaseDriver import BaseDriver + + +class OAuth: + + def add_driver(self, name:str, driver:"BaseDriver"): + """Register a new driver for OAuth2.""" + ... + + def set_configuration(self, config:dict) -> OAuth: + """Set configuration for OAuth2.""" + ... + + def driver(self, name:str) -> "BaseDriver": + """Get OAuth2 instance for given driver.""" + ... + + def get_config_options(self, driver:str=None) -> dict: + """Get configuration options for a given driver or the default driver.""" + ... diff --git a/src/masonite/oauth/drivers/BaseDriver.py b/src/masonite/oauth/drivers/BaseDriver.py index 00e93c3..7f6d820 100644 --- a/src/masonite/oauth/drivers/BaseDriver.py +++ b/src/masonite/oauth/drivers/BaseDriver.py @@ -1,9 +1,12 @@ import requests -import json +from typing import TYPE_CHECKING 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 + +if TYPE_CHECKING: + from ..OAuthUser import OAuthUser class BaseDriver: @@ -14,6 +17,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 +34,76 @@ 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_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): - 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) + state = None + + if self.use_state(): + state = self.get_state() + # self.application.make("session").set("state", state) + self.application.make("request").session.set("state", state) + + authorization_url = self.build_auth_url(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, - ) - token = response.get("access_token") - return client, token + data = self.get_token_fields(code) + response = requests.post(self.get_token_url(), data, headers={"Accept": "application/json"}) + if response.status_code != 200: + raise Exception("error") + data = response.json() + token = data.get("access_token") + return token 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 +115,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.""" @@ -86,7 +135,7 @@ def has_invalid_state(self): 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): @@ -107,22 +156,28 @@ def get_email_url(self): def get_request_options(self, *args): raise NotImplementedError() + def map_user_data(self, data) -> "OAuthUser": + 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 + return response.json() 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 + if self.has_invalid_state(): + raise Exception("Invalid state") + token = self.get_token() + user = self.user_from_token(token) + + return user def user_from_token(self, token): 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() + email = self.get_email_by_token(token) + user = self.map_user_data({**data, "email": email}) + user.set_token(token) + return user + diff --git a/src/masonite/oauth/drivers/GithubDriver.py b/src/masonite/oauth/drivers/GithubDriver.py index 04f0e24..732ff45 100644 --- a/src/masonite/oauth/drivers/GithubDriver.py +++ b/src/masonite/oauth/drivers/GithubDriver.py @@ -37,40 +37,13 @@ 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( + def map_user_data(self, data): + return OAuthUser().build( { - "id": user_data["id"], - "nickname": user_data["login"], - "name": user_data["name"], - "email": user_data["email"] or email, - "avatar": user_data["avatar_url"], + "id": data["id"], + "nickname": data["login"], + "name": data["name"], + "email": data["email"], + "avatar": data["avatar_url"], } ) - ) - return user - - 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"], - } - ) - ) - return user From b63bef1e9d854864088db50393756080487d5093 Mon Sep 17 00:00:00 2001 From: Samuel Girardin Date: Wed, 23 Mar 2022 17:25:27 +0100 Subject: [PATCH 3/4] refactor --- README.md | 10 ++ requirements.txt | 1 - setup.py | 4 +- src/masonite/oauth/OAuth.py | 6 +- src/masonite/oauth/OAuthFacade.pyi | 13 +-- src/masonite/oauth/OAuthUser.py | 12 ++ src/masonite/oauth/drivers/AppleDriver.py | 42 ++----- src/masonite/oauth/drivers/BaseDriver.py | 106 +++++++++++++----- src/masonite/oauth/drivers/BitbucketDriver.py | 61 +++++----- src/masonite/oauth/drivers/FacebookDriver.py | 45 ++------ src/masonite/oauth/drivers/GithubDriver.py | 52 +++++++-- src/masonite/oauth/drivers/GitlabDriver.py | 56 ++++----- src/masonite/oauth/drivers/GoogleDriver.py | 53 ++++----- tests/integrations/Kernel.py | 1 + tests/unit/test_providers.py | 2 - 15 files changed, 241 insertions(+), 223 deletions(-) diff --git a/README.md b/README.md index 0a6f301..b52bca2 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. 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 index bdf0c1a..a18ad31 100644 --- a/src/masonite/oauth/OAuthFacade.pyi +++ b/src/masonite/oauth/OAuthFacade.pyi @@ -3,21 +3,16 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: from .drivers.BaseDriver import BaseDriver - class OAuth: - - def add_driver(self, name:str, driver:"BaseDriver"): + def add_driver(name: str, driver: "BaseDriver"): """Register a new driver for OAuth2.""" ... - - def set_configuration(self, config:dict) -> OAuth: + def set_configuration(config: dict) -> OAuth: """Set configuration for OAuth2.""" ... - - def driver(self, name:str) -> "BaseDriver": + def driver(name: str) -> "BaseDriver": """Get OAuth2 instance for given driver.""" ... - - def get_config_options(self, driver:str=None) -> dict: + 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 7f6d820..78bdc38 100644 --- a/src/masonite/oauth/drivers/BaseDriver.py +++ b/src/masonite/oauth/drivers/BaseDriver.py @@ -1,12 +1,11 @@ import requests -from typing import TYPE_CHECKING from urllib.parse import urlencode + from masonite.exceptions import RouteNotFoundException from masonite.facades import Url from masonite.utils.str import random_string -if TYPE_CHECKING: - from ..OAuthUser import OAuthUser +from ..OAuthUser import OAuthUser class BaseDriver: @@ -34,20 +33,12 @@ 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" + "response_type": "code", } if self.use_state(): params["state"] = state @@ -62,14 +53,14 @@ def build_auth_url(self, state): query = urlencode(params) return f"{base_url}?{query}" - def redirect(self): - state = None - + def redirect(self, state=None): if self.use_state(): - state = self.get_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) return self.application.make("response").redirect(location=authorization_url) @@ -87,12 +78,13 @@ def get_token_fields(self, code): def get_token(self): code = self.application.make("request").input("code") data = self.get_token_fields(code) - response = requests.post(self.get_token_url(), data, headers={"Accept": "application/json"}) + response = requests.post( + self.get_token_url(), data, headers={"Accept": "application/json"} + ) + data = response.json() if response.status_code != 200: raise Exception("error") - data = response.json() - token = data.get("access_token") - return token + return data def stateless(self): self._is_stateless = True @@ -131,7 +123,6 @@ 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") @@ -147,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() @@ -159,25 +156,76 @@ def get_request_options(self, *args): 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)) - return response.json() + 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): + def user(self) -> "OAuthUser": if self.has_invalid_state(): raise Exception("Invalid state") - token = self.get_token() + 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): + def user_from_token(self, token: str) -> "OAuthUser": response = requests.get(self.get_user_url(), **self.get_request_options(token)) data = response.json() + if response.status_code != 200: + raise Exception("Provider API error") email = self.get_email_by_token(token) - user = self.map_user_data({**data, "email": email}) + 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 732ff45..b016340 100644 --- a/src/masonite/oauth/drivers/GithubDriver.py +++ b/src/masonite/oauth/drivers/GithubDriver.py @@ -38,12 +38,46 @@ def get_email_by_token(self, token): return email def map_user_data(self, data): - return OAuthUser().build( - { - "id": data["id"], - "nickname": data["login"], - "name": data["name"], - "email": data["email"], - "avatar": data["avatar_url"], - } - ) + 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"}, + ) + 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) + + 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") From 013a8ad5a863f7a6491d4f386bd7e0230f616525 Mon Sep 17 00:00:00 2001 From: Samuel Girardin Date: Wed, 23 Mar 2022 17:29:43 +0100 Subject: [PATCH 4/4] add doc for new features --- README.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/README.md b/README.md index b52bca2..b79967f 100644 --- a/README.md +++ b/README.md @@ -160,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.