Skip to content

Commit 99448d5

Browse files
authored
Support OAuth authentication method (#5)
* Support OAuth authentication method
1 parent dc805a8 commit 99448d5

File tree

5 files changed

+195
-2
lines changed

5 files changed

+195
-2
lines changed

descope/auth.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
SESSION_COOKIE_NAME,
2121
DeliveryMethod,
2222
EndpointsV1,
23+
OAuthProviders,
2324
User,
2425
)
2526
from descope.exceptions import AuthException
@@ -614,6 +615,14 @@ def validate_session_request(
614615
def logout(
615616
self, signed_token: str, signed_refresh_token: str
616617
) -> requests.cookies.RequestsCookieJar:
618+
619+
if signed_token is None or signed_refresh_token is None:
620+
raise AuthException(
621+
401,
622+
"token validation failure",
623+
f"signed token {signed_token} or/and signed refresh token {signed_refresh_token} are empty",
624+
)
625+
617626
uri = AuthClient._compose_logout_url()
618627
cookies = {
619628
SESSION_COOKIE_NAME: signed_token,
@@ -642,3 +651,38 @@ def _get_default_headers(self):
642651
bytes = f"{self.project_id}:".encode("ascii")
643652
headers["Authorization"] = f"Basic {base64.b64encode(bytes).decode('ascii')}"
644653
return headers
654+
655+
@staticmethod
656+
def _verify_oauth_provider(oauth_provider: str) -> str:
657+
if oauth_provider == "" or oauth_provider is None:
658+
return False
659+
660+
if oauth_provider in OAuthProviders:
661+
return True
662+
else:
663+
return False
664+
665+
def oauth_start(self, provider: str) -> str:
666+
""" """
667+
if not self._verify_oauth_provider(provider):
668+
raise AuthException(
669+
500,
670+
"Unknown OAuth provider",
671+
f"Unknown OAuth provider: {provider}",
672+
)
673+
674+
uri = f"{DEFAULT_BASE_URI}{EndpointsV1.oauthStart}"
675+
response = requests.get(
676+
uri,
677+
headers=self._get_default_headers(),
678+
params={"provider": provider},
679+
allow_redirects=False,
680+
)
681+
682+
if not response.ok:
683+
raise AuthException(
684+
response.status_code, "OAuth send request failure", response.text
685+
)
686+
687+
redirect_url = response.headers.get("Location", "")
688+
return redirect_url

descope/common.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
SESSION_COOKIE_NAME = "DS"
99
REFRESH_SESSION_COOKIE_NAME = "DSR"
1010

11+
REDIRECT_LOCATION_COOKIE_NAME = "Location"
12+
1113

1214
class EndpointsV1:
1315
signInAuthOTPPath = "/v1/auth/signin/otp"
@@ -16,6 +18,7 @@ class EndpointsV1:
1618
signInAuthMagicLinkPath = "/v1/auth/signin/magiclink"
1719
signUpAuthMagicLinkPath = "/v1/auth/signup/magiclink"
1820
verifyMagicLinkAuthPath = "/v1/auth/magiclink/verify"
21+
oauthStart = "/v1/oauth/authorize"
1922
publicKeyPath = "/v1/keys"
2023
refreshTokenPath = "/v1/auth/refresh"
2124
logoutPath = "/v1/auth/logoutall"
@@ -27,6 +30,9 @@ class DeliveryMethod(Enum):
2730
EMAIL = 3
2831

2932

33+
OAuthProviders = ["facebook", "github", "google", "microsoft", "gitlab", "apple"]
34+
35+
3036
class User:
3137
def __init__(self, username: str, name: str, phone: str, email: str):
3238
self.username = username

samples/decorators/flask_decorators.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import sys
33
from functools import wraps
44

5-
from flask import Response, _request_ctx_stack, request
5+
from flask import Response, _request_ctx_stack, redirect, request
66

77
dir_name = os.path.dirname(__file__)
88
sys.path.insert(0, os.path.join(dir_name, "../"))
@@ -329,3 +329,29 @@ def decorated(*args, **kwargs):
329329
return decorated
330330

331331
return decorator
332+
333+
334+
def descope_oauth(auth_client):
335+
"""
336+
OAuth login
337+
"""
338+
339+
def decorator(f):
340+
@wraps(f)
341+
def decorated(*args, **kwargs):
342+
try:
343+
args = request.args
344+
provider = args.get("provider")
345+
redirect_url = auth_client.oauth_start(provider)
346+
except AuthException as e:
347+
return Response(f"OAuth failed {e}", e.status_code)
348+
349+
# Execute the original API
350+
# (ignore return value as anyway we redirect)
351+
f(*args, **kwargs)
352+
353+
return redirect(redirect_url, 302)
354+
355+
return decorated
356+
357+
return decorator

samples/oauth_web_sample_app.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import os
2+
import sys
3+
4+
from flask import Flask, jsonify
5+
6+
dir_name = os.path.dirname(__file__)
7+
sys.path.insert(0, os.path.join(dir_name, "../"))
8+
from decorators.flask_decorators import ( # noqa: E402;
9+
descope_logout,
10+
descope_oauth,
11+
descope_validate_auth,
12+
)
13+
14+
from descope import AuthClient # noqa: E402
15+
16+
APP = Flask(__name__)
17+
18+
PROJECT_ID = ""
19+
20+
# init the AuthClient
21+
auth_client = AuthClient(PROJECT_ID)
22+
23+
24+
class Error(Exception):
25+
def __init__(self, error, status_code):
26+
self.error = error
27+
self.status_code = status_code
28+
29+
30+
@APP.errorhandler(Error)
31+
def handle_auth_error(ex):
32+
response = jsonify(ex.error)
33+
response.status_code = ex.status_code
34+
return response
35+
36+
37+
# This needs authentication
38+
@APP.route("/api/private")
39+
@descope_validate_auth(auth_client)
40+
def private():
41+
response = "This is a private API and you must be authenticated to see this"
42+
return jsonify(message=response)
43+
44+
45+
@APP.route("/api/logout")
46+
@descope_logout(auth_client)
47+
def logout():
48+
response = "Logged out"
49+
return jsonify(message=response)
50+
51+
52+
@APP.route("/api/oauth", methods=["GET"])
53+
@descope_oauth(auth_client)
54+
def oauth(*args, **kwargs):
55+
pass
56+
57+
58+
# This doesn't need authentication
59+
@APP.route("/")
60+
def home():
61+
return "OK"
62+
63+
64+
if __name__ == "__main__":
65+
APP.run(host="127.0.0.1", port=9000)

tests/test_auth.py

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from unittest.mock import patch
66

77
from descope import SESSION_COOKIE_NAME, AuthClient, AuthException, DeliveryMethod, User
8-
from descope.common import REFRESH_SESSION_COOKIE_NAME
8+
from descope.common import DEFAULT_BASE_URI, REFRESH_SESSION_COOKIE_NAME, EndpointsV1
99

1010

1111
class TestAuthClient(unittest.TestCase):
@@ -172,6 +172,56 @@ class AAA(Enum):
172172
False,
173173
)
174174

175+
def test_verify_oauth_providers(self):
176+
self.assertEqual(
177+
AuthClient._verify_oauth_provider(""),
178+
False,
179+
)
180+
181+
self.assertEqual(
182+
AuthClient._verify_oauth_provider(None),
183+
False,
184+
)
185+
186+
self.assertEqual(
187+
AuthClient._verify_oauth_provider("unknown provider"),
188+
False,
189+
)
190+
191+
self.assertEqual(
192+
AuthClient._verify_oauth_provider("google"),
193+
True,
194+
)
195+
196+
def test_oauth_start(self):
197+
client = AuthClient(self.dummy_project_id, self.public_key_dict)
198+
199+
# Test failed flows
200+
self.assertRaises(AuthException, client.oauth_start, "")
201+
202+
with patch("requests.get") as mock_get:
203+
mock_get.return_value.ok = False
204+
self.assertRaises(AuthException, client.oauth_start, "google")
205+
206+
# Test success flow
207+
with patch("requests.get") as mock_get:
208+
mock_get.return_value.ok = True
209+
self.assertIsNotNone(client.oauth_start("google"))
210+
211+
with patch("requests.get") as mock_get:
212+
mock_get.return_value.ok = True
213+
client.oauth_start("facebook")
214+
expected_uri = f"{DEFAULT_BASE_URI}{EndpointsV1.oauthStart}"
215+
mock_get.assert_called_with(
216+
expected_uri,
217+
headers={
218+
"Content-Type": "application/json",
219+
"Authorization": "Basic ZHVtbXk6",
220+
},
221+
params={"provider": "facebook"},
222+
allow_redirects=False,
223+
)
224+
175225
def test_get_identifier_name_by_method(self):
176226
self.assertEqual(
177227
AuthClient._get_identifier_name_by_method(DeliveryMethod.EMAIL), "email"
@@ -277,6 +327,8 @@ def test_logout(self):
277327
dummy_valid_jwt_token = ""
278328
client = AuthClient(self.dummy_project_id, self.public_key_dict)
279329

330+
self.assertRaises(AuthException, client.logout, None, None)
331+
280332
# Test failed flow
281333
with patch("requests.get") as mock_get:
282334
mock_get.return_value.ok = False

0 commit comments

Comments
 (0)