Coverage for webapp/login/views.py: 92%

60 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-18 22:11 +0000

1import os 

2from urllib.parse import urlparse 

3 

4import flask 

5from flask_openid import OpenID 

6from pymacaroons import Macaroon 

7 

8from webapp import authentication 

9from webapp.extensions import csrf 

10from webapp.helpers import is_safe_url 

11from webapp.login.macaroon import MacaroonRequest, MacaroonResponse 

12from webapp.observability.utils import trace_function 

13from webapp.store_api import publisher_gateway 

14 

15login = flask.Blueprint( 

16 "login", __name__, template_folder="/templates", static_folder="/static" 

17) 

18 

19LOGIN_URL = os.getenv("FLASK_LOGIN_URL", "https://login.ubuntu.com") 

20LOGIN_USSO_TTL = int(os.getenv("FLASK_LOGIN_USSO_TTL", "300")) 

21 

22open_id = OpenID( 

23 store_factory=lambda: None, 

24 safe_roots=[], 

25 extension_responses=[MacaroonResponse], 

26) 

27 

28 

29def get_caveat_id(root: str): 

30 location = urlparse(LOGIN_URL).hostname 

31 caveat = next( 

32 ( 

33 c 

34 for c in Macaroon.deserialize(root).third_party_caveats() 

35 if c.location == location 

36 ), 

37 None, 

38 ) 

39 if caveat is None: 

40 caveat = next( 

41 ( 

42 c 

43 for c in Macaroon.deserialize(root).third_party_caveats() 

44 if urlparse(f"https://{c.location}").hostname == location 

45 ), 

46 None, 

47 ) 

48 if caveat is None: 

49 raise ValueError("No third-party caveat found on root macaroon") 

50 return caveat.caveat_id 

51 

52 

53@trace_function 

54@login.route("/logout") 

55def logout(): 

56 authentication.empty_session(flask.session) 

57 return flask.redirect("/") 

58 

59 

60@trace_function 

61@login.route("/login", methods=["GET", "POST"]) 

62@csrf.exempt 

63@open_id.loginhandler 

64def publisher_login(): 

65 if authentication.is_authenticated(flask.session): 

66 return flask.redirect("/") 

67 

68 flask.session["account-macaroon"] = publisher_gateway.issue_usso_macaroon( 

69 ttl=LOGIN_USSO_TTL, 

70 permissions=[ 

71 "account-register-package", 

72 "account-view-packages", 

73 "package-manage", 

74 "package-view", 

75 ], 

76 ) 

77 

78 openid_macaroon = MacaroonRequest( 

79 caveat_id=get_caveat_id(flask.session["account-macaroon"]) 

80 ) 

81 

82 next_url = flask.request.args.get("next") 

83 if next_url: 

84 if not is_safe_url(next_url): 

85 return flask.abort(400) 

86 flask.session["next_url"] = next_url 

87 

88 return open_id.try_login( 

89 LOGIN_URL, 

90 ask_for=["email", "nickname", "image"], 

91 ask_for_optional=["fullname"], 

92 extensions=[openid_macaroon], 

93 ) 

94 

95 

96@open_id.after_login 

97def login_callback(resp): 

98 discharge = resp.extensions.get("macaroon") 

99 discharge_macaroon = getattr(discharge, "discharge", None) 

100 if not discharge_macaroon: 

101 return flask.abort( 

102 502, "Ubuntu SSO login did not return macaroon discharge" 

103 ) 

104 

105 root_macaroon = flask.session.get("account-macaroon") 

106 if not root_macaroon: 

107 next_url = flask.session.get("next_url", "/charms") 

108 authentication.empty_session(flask.session) 

109 return flask.redirect( 

110 flask.url_for(".publisher_login", next=next_url), 302 

111 ) 

112 

113 bound_discharge = Macaroon.deserialize(root_macaroon).prepare_for_request( 

114 Macaroon.deserialize(discharge_macaroon) 

115 ) 

116 

117 user_agent = flask.request.headers.get("User-Agent") 

118 client_description = f"charmhub.io - {user_agent}" if user_agent else None 

119 

120 flask.session["account-auth"] = publisher_gateway.exchange_usso_macaroons( 

121 root_macaroon=root_macaroon, 

122 discharge_macaroon=bound_discharge.serialize(), 

123 client_description=client_description, 

124 ) 

125 

126 flask.session.update( 

127 publisher_gateway.macaroon_info(flask.session["account-auth"]) 

128 ) 

129 

130 return flask.redirect(flask.session.pop("next_url", "/charms"), 302)