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
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-18 22:11 +0000
1import os
2from urllib.parse import urlparse
4import flask
5from flask_openid import OpenID
6from pymacaroons import Macaroon
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
15login = flask.Blueprint(
16 "login", __name__, template_folder="/templates", static_folder="/static"
17)
19LOGIN_URL = os.getenv("FLASK_LOGIN_URL", "https://login.ubuntu.com")
20LOGIN_USSO_TTL = int(os.getenv("FLASK_LOGIN_USSO_TTL", "300"))
22open_id = OpenID(
23 store_factory=lambda: None,
24 safe_roots=[],
25 extension_responses=[MacaroonResponse],
26)
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
53@trace_function
54@login.route("/logout")
55def logout():
56 authentication.empty_session(flask.session)
57 return flask.redirect("/")
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("/")
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 )
78 openid_macaroon = MacaroonRequest(
79 caveat_id=get_caveat_id(flask.session["account-macaroon"])
80 )
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
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 )
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 )
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 )
113 bound_discharge = Macaroon.deserialize(root_macaroon).prepare_for_request(
114 Macaroon.deserialize(discharge_macaroon)
115 )
117 user_agent = flask.request.headers.get("User-Agent")
118 client_description = f"charmhub.io - {user_agent}" if user_agent else None
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 )
126 flask.session.update(
127 publisher_gateway.macaroon_info(flask.session["account-auth"])
128 )
130 return flask.redirect(flask.session.pop("next_url", "/charms"), 302)