Skip to content

Commit 8345836

Browse files
committed
Merge tag '0.9.2' into microsoft-main
2 parents ae30aff + 325350f commit 8345836

9 files changed

Lines changed: 115 additions & 58 deletions

File tree

identity/django.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from functools import partial, wraps
22
import logging
33
import os
4-
from typing import List # Needed in Python 3.7 & 3.8
4+
from typing import List, Optional # Needed in Python 3.7 & 3.8
55
from urllib.parse import urlparse
66

77
from django.shortcuts import redirect, render
@@ -50,9 +50,9 @@ def __init__(self, *args, **kwargs):
5050
def login(
5151
self,
5252
request,
53-
next_link:str = None, # Obtain the next_link from the app developer,
53+
next_link: Optional[str] = None, # Obtain the next_link from the app developer,
5454
# NOT from query string which could become an open redirect vulnerability.
55-
scopes: List[str]=None,
55+
scopes: Optional[List[str]] = None,
5656
):
5757
# The login view.
5858
# App developer could redirect to the login page from inside a view,
@@ -117,7 +117,7 @@ def login_required( # Named after Django's login_required
117117
function=None,
118118
/, # Requires Python 3.8+
119119
*,
120-
scopes: List[str]=None,
120+
scopes: Optional[List[str]] = None,
121121
):
122122
"""A decorator that ensures the user to be logged in,
123123
and optinoally also have consented to a list of scopes.

identity/flask.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,20 +62,28 @@ def build_app():
6262
self._session = session # Not available during class definition
6363
super(Auth, self).__init__(app, *args, **kwargs)
6464

65-
def _render_auth_error(self, *, error, error_description=None):
65+
def _render_auth_error( # type: ignore[override]
66+
self, *, error, error_description=None,
67+
) -> str:
6668
return render_template(
6769
f"{self._endpoint_prefix}/auth_error.html",
6870
error=error,
6971
error_description=error_description,
7072
reset_password_url=self._get_reset_password_url(),
7173
)
7274

73-
def login(self, *, next_link: str=None, scopes: List[str]=None):
75+
def login(
76+
self,
77+
*,
78+
next_link: Optional[str] = None,
79+
scopes: Optional[List[str]] = None,
80+
) -> str:
7481
config_error = self._get_configuration_error()
7582
if config_error:
7683
return self._render_auth_error(
7784
error="configuration_error", error_description=config_error)
78-
log_in_result = self._auth.log_in(
85+
assert self._auth, "_auth should have been initialized" # And mypy needs this
86+
log_in_result: dict = self._auth.log_in(
7987
scopes=scopes, # Have user consent to scopes (if any) during log-in
8088
redirect_uri=self._redirect_uri,
8189
prompt="select_account", # Optional. More values defined in https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest
@@ -106,7 +114,7 @@ def login_required( # Named after Django's login_required
106114
function=None,
107115
/, # Requires Python 3.8+
108116
*,
109-
scopes: List[str]=None,
117+
scopes: Optional[List[str]] = None,
110118
):
111119
"""A decorator that ensures the user to be logged in,
112120
and optinoally also have consented to a list of scopes.

identity/pallet.py

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,17 @@
33
from inspect import iscoroutinefunction
44
import logging
55
import os
6-
from typing import List # Needed in Python 3.7 & 3.8
6+
from typing import List, Optional # Needed in Python 3.7 & 3.8
77
from urllib.parse import urlparse
8-
from .web import WebFrameworkAuth
8+
from .web import WebFrameworkAuth, Auth
99

1010

1111
logger = logging.getLogger(__name__)
1212

1313

1414
class PalletAuth(WebFrameworkAuth): # A common base class for Flask and Quart
1515
_endpoint_prefix = "identity" # A convention to match the template's folder name
16-
_auth = None # None means not initialized yet
16+
_auth: Optional[Auth] = None # None means not initialized yet
1717

1818
def __init__(self, app, *args, **kwargs):
1919
if not (
@@ -64,14 +64,14 @@ def __getattribute__(self, name):
6464

6565
def logout(self):
6666
return self.__class__._redirect( # self._redirect(...) won't work
67-
self._auth.log_out(self._request.host_url))
67+
self._auth.log_out(self._request.url_root))
6868

6969
def login_required( # Named after Django's login_required
7070
self,
7171
function=None,
7272
/, # Requires Python 3.8+
7373
*,
74-
scopes: List[str]=None,
74+
scopes: Optional[List[str]] = None,
7575
):
7676
# With or without brackets. Inspired by https://stackoverflow.com/a/39335652/728675
7777

@@ -92,7 +92,7 @@ async def wrapper(*args, **kwargs):
9292
if context:
9393
return await function(*args, context=context, **kwargs)
9494
# Save an http 302 by calling self.login(request) instead of redirect(self.login)
95-
return await self.login(next_link=self._request.path, scopes=scopes)
95+
return await self.login(next_link=self._request.url, scopes=scopes)
9696
else: # For Flask
9797
@wraps(function)
9898
def wrapper(*args, **kwargs):
@@ -101,9 +101,6 @@ def wrapper(*args, **kwargs):
101101
if context:
102102
return function(*args, context=context, **kwargs)
103103
# Save an http 302 by calling self.login(request) instead of redirect(self.login)
104-
return self.login(
105-
next_link=self._request.path, # https://flask.palletsprojects.com/en/3.0.x/api/#flask.Request.path
106-
scopes=scopes,
107-
)
104+
return self.login(next_link=self._request.url, scopes=scopes)
108105
return wrapper
109106

identity/quart.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,11 +70,17 @@ async def _render_auth_error(self, *, error, error_description=None):
7070
reset_password_url=self._get_reset_password_url(),
7171
)
7272

73-
async def login(self, *, next_link: str=None, scopes: List[str]=None):
73+
async def login(
74+
self,
75+
*,
76+
next_link: Optional[str] = None,
77+
scopes: Optional[List[str]] = None,
78+
):
7479
config_error = self._get_configuration_error()
7580
if config_error:
7681
return await self._render_auth_error(
7782
error="configuration_error", error_description=config_error)
83+
assert self._auth, "_auth should have been initialized" # And mypy needs this
7884
log_in_result = self._auth.log_in(
7985
scopes=scopes, # Have user consent to scopes (if any) during log-in
8086
redirect_uri=self._redirect_uri,
@@ -106,7 +112,7 @@ def login_required( # Named after Django's login_required
106112
function=None,
107113
/, # Requires Python 3.8+
108114
*,
109-
scopes: List[str]=None,
115+
scopes: Optional[List[str]] = None,
110116
):
111117
"""A decorator that ensures the user to be logged in,
112118
and optinoally also have consented to a list of scopes.

identity/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.9.1" # Note: Perhaps update ReadTheDocs and README.md too?
1+
__version__ = "0.9.2" # Note: Perhaps update ReadTheDocs and README.md too?

identity/web.py

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import functools
33
import logging
44
import time
5-
from typing import List # Needed in Python 3.7 & 3.8
5+
from typing import List, Optional # Needed in Python 3.7 & 3.8
66

77
import requests
88
import msal
@@ -97,9 +97,14 @@ def _save_user_into_session(self, id_token_claims):
9797
self._session[self._USER] = id_token_claims
9898

9999
def log_in(
100-
self, scopes=None, redirect_uri=None, state=None, prompt=None,
101-
next_link=None,
102-
):
100+
self,
101+
*,
102+
scopes: Optional[List[str]] = None,
103+
redirect_uri: Optional[str] = None,
104+
state: Optional[str] = None,
105+
prompt: Optional[str] = None,
106+
next_link: Optional[str] = None,
107+
) -> dict:
103108
"""This is the first leg of the authentication/authorization.
104109
105110
:param list scopes:
@@ -162,7 +167,7 @@ def log_in(
162167
"user_code": flow["user_code"],
163168
}
164169

165-
def complete_log_in(self, auth_response=None):
170+
def complete_log_in(self, auth_response: Optional[dict] = None) -> dict:
166171
"""This is the second leg of the authentication/authorization.
167172
168173
It is used inside your redirect_uri controller.
@@ -356,15 +361,15 @@ def __init__(
356361
client_id: str,
357362
*,
358363
client_credential=None,
359-
oidc_authority: str=None,
360-
authority: str=None,
361-
redirect_uri: str=None,
364+
oidc_authority: Optional[str] = None,
365+
authority: Optional[str] = None,
366+
redirect_uri: Optional[str] = None,
362367
# We end up accepting Microsoft Entra ID B2C parameters rather than generic urls
363368
# because it is troublesome to build those urls in settings.py or templates
364-
b2c_tenant_name: str=None,
365-
b2c_signup_signin_user_flow: str=None,
366-
b2c_edit_profile_user_flow: str=None,
367-
b2c_reset_password_user_flow: str=None,
369+
b2c_tenant_name: Optional[str] = None,
370+
b2c_signup_signin_user_flow: Optional[str] = None,
371+
b2c_edit_profile_user_flow: Optional[str] = None,
372+
b2c_reset_password_user_flow: Optional[str] = None,
368373
):
369374
"""Create an identity helper for a web application.
370375
@@ -419,8 +424,9 @@ def __init__(
419424
self._client_id = client_id
420425
self._client_credential = client_credential
421426
self._redirect_uri = redirect_uri
422-
self._http_cache = {} # All subsequent Auth instances will share this
427+
self._http_cache: dict = {} # All subsequent Auth instances will share this
423428

429+
self._authority: Optional[str] = None # It makes mypy happy
424430
# Note: We do not use overload, because we want to allow the caller to
425431
# have only one code path that relay in all the optional parameters.
426432
if b2c_tenant_name and b2c_signup_signin_user_flow:
@@ -467,7 +473,7 @@ def _get_configuration_error(self):
467473
(2.3) the B2C_TENANT_NAME and SIGNUPSIGNIN_USER_FLOW pair?
468474
"""
469475

470-
def _build_auth(self, session):
476+
def _build_auth(self, session) -> Auth:
471477
return Auth(
472478
session=session,
473479
oidc_authority=self._oidc_authority,
@@ -523,7 +529,9 @@ def _get_reset_password_url(self):
523529
)["auth_uri"] if self._reset_password_auth and self._redirect_uri else None
524530

525531
@abstractmethod
526-
def _render_auth_error(error, *, error_description=None):
532+
def _render_auth_error(
533+
error, *, error_description=None,
534+
): # Return value could be a str, or a framework-specific Response object
527535
# The default auth_error.html template may or may not escape.
528536
# If a web framework does not escape it by default, a subclass shall escape it.
529537
pass

setup.cfg

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ django =
6464
flask =
6565
flask
6666
# If Flask-Session is not maintained in future, Flask-Session2 should work as well
67-
Flask-Session>=0.3.2,<0.6
67+
Flask-Session >= 0.3.2, !=0.7.0, <0.9
6868
quart =
6969
quart
7070
# Note that Quart-session is a no-op by default.

tests/test_flask.py

Lines changed: 46 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,60 @@
1+
import shutil
12
from unittest.mock import patch, Mock
23

4+
import pytest
35
from flask import Flask
46

57
from identity.flask import Auth
68

79

8-
def test_logout():
10+
@pytest.fixture()
11+
def app(): # https://flask.palletsprojects.com/en/3.0.x/testing/
912
app = Flask(__name__)
10-
app.config["SESSION_TYPE"] = "filesystem" # Required for Flask-session,
11-
# see also https://stackoverflow.com/questions/26080872
12-
auth = Auth(app, client_id="fake")
13-
with app.test_request_context("/", method="GET"):
14-
assert auth._request.host_url in auth.logout().get_data(as_text=True), (
15-
"The host_url should be in the logout URL. There was a bug in 0.9.0.")
13+
app.config.update({
14+
"APPLICATION_ROOT": "/app_root", # Mimicking app with explicit root
15+
"SESSION_TYPE": "filesystem", # Required for Flask-session,
16+
# see also https://stackoverflow.com/questions/26080872
17+
})
18+
yield app
19+
shutil.rmtree("flask_session") # clean up
20+
21+
@pytest.fixture()
22+
def auth(app):
23+
return Auth(
24+
app,
25+
client_id="fake",
26+
redirect_uri="http://localhost:5000/redirect", # To use auth code flow
27+
oidc_authority="https://example.com/foo",
28+
)
29+
30+
def test_logout(app, auth):
31+
with patch.object(auth._auth, "_get_oidc_config", new=Mock(return_value={
32+
"end_session_endpoint": "https://example.com/end_session",
33+
})):
34+
with app.test_request_context("/", method="GET"):
35+
homepage = "http://localhost/app_root"
36+
assert homepage in auth.logout().get_data(as_text=True), (
37+
"The homepage should be in the logout URL. There was a bug in 0.9.0.")
1638

1739
@patch("msal.authority.tenant_discovery", new=Mock(return_value={
1840
"authorization_endpoint": "https://example.com/placeholder",
1941
"token_endpoint": "https://example.com/placeholder",
2042
}))
21-
def test_login_should_locate_its_template():
22-
app = Flask(__name__)
23-
app.config["SESSION_TYPE"] = "filesystem" # Required for Flask-session,
24-
# see also https://stackoverflow.com/questions/26080872
25-
client_id = str(hash(app))
26-
auth = Auth(
27-
app,
28-
client_id=client_id,
29-
redirect_uri="http://localhost:5000/redirect", # To use auth code flow
30-
oidc_authority="https://example.com/foo",
31-
)
32-
with app.test_request_context("/", method="GET"):
33-
assert client_id in auth.login() # Proper template output contains client_id
43+
def test_login(app, auth):
44+
45+
@app.route("/path")
46+
@auth.login_required
47+
def dummy_view():
48+
return "content visible after login"
49+
50+
with app.test_request_context("/path", method="GET"):
51+
should_find_template = "login() should have template to render"
52+
assert auth._client_id in auth.login(), should_find_template
53+
with app.test_client() as client:
54+
result = client.get("/path?foo=bar")
55+
assert auth._client_id in str(result.data), should_find_template
56+
from flask import session # It is different than auth._auth._session
57+
assert session.get("_auth_flow", {}).get("identity.web.next_link") == (
58+
"http://localhost/app_root/path?foo=bar" # The full url
59+
), "Next path should honor APPLICATION_ROOT"
3460

tox.ini

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
[tox]
2+
# So the following name can be referenced in other sections such as testenv:type
3+
x_project_name = identity
4+
25
env_list =
36
py3
47
minversion = 4.21.2
@@ -12,4 +15,13 @@ deps =
1215
-r requirements.txt
1316
commands =
1417
pip list
15-
pytest {tty:--color=yes} -o asyncio_default_fixture_loop_scope=function {posargs}
18+
pytest {tty:--color=yes} -o asyncio_default_fixture_loop_scope=function -rA {posargs}
19+
20+
[testenv:type]
21+
deps =
22+
-r requirements.txt
23+
mypy
24+
commands =
25+
mypy --install-types --non-interactive
26+
mypy {[tox]x_project_name}
27+

0 commit comments

Comments
 (0)