Skip to content

Commit 7097c10

Browse files
committed
Added an external login to djangoproject.com.
1 parent ca9f928 commit 7097c10

11 files changed

Lines changed: 945 additions & 2 deletions

File tree

.TRACFREEZE.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# generated by traccheck.py on 2026-04-27 09:42:14 with Trac version 1.6
1+
# generated by traccheck.py on 2026-07-17 05:42:27 with Trac version 1.6
22
trac.admin.api.admincommandmanager
33
trac.admin.console.tracadminhelpmacro
44
trac.admin.web_ui.adminmodule
@@ -109,6 +109,7 @@ tracdjangoplugin.plugins.githubbrowserwithsvnchangesets
109109
tracdjangoplugin.plugins.plainlogincomponent
110110
tracdjangoplugin.plugins.reservedusernamescomponent
111111
tracdjangoplugin.plugins.timelineticketcomponentfilter
112+
tracdjangoprojectauth.plugins.djangoexternalloginmodule
112113
tracdragdrop.web_ui.tracdragdropmodule
113114
tracext.github.githubloginmodule
114115
tracext.github.githubpostcommithook

.github/workflows/tests.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,23 @@ jobs:
5050
- name: Run tests
5151
run: python -m django test tracdjangoplugin.tests
5252

53+
tracdjangoprojectauthplugin:
54+
runs-on: ubuntu-24.04
55+
steps:
56+
- name: Checkout
57+
uses: actions/checkout@v4
58+
- uses: actions/setup-python@v5
59+
with:
60+
python-version: "3.11"
61+
- name: Install system package dependencies
62+
run: |
63+
sudo apt-get update
64+
sudo apt-get -y install subversion
65+
- name: Install requirements
66+
run: python -m pip install -r requirements.txt
67+
- name: Run tests
68+
run: python -m unittest -v ExternalAuthPlugin.tracdjangoprojectauth.tests
69+
5370
traccheck:
5471
runs-on: ubuntu-24.04
5572
steps:

DjangoPlugin/tracdjangoplugin/plugins.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import os
12
from urllib.parse import urlparse
23

34
from trac.config import ListOption
@@ -243,6 +244,7 @@ def do_get(self, req):
243244
return "plainlogin.html", {
244245
"form": AuthenticationForm(),
245246
"referer": req.args.get("referer", ""),
247+
"external_auth_enabled": bool(os.environ.get("DJANGO_TRAC_AUTH_SECRET")),
246248
}
247249

248250
def do_post(self, req):

Dockerfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ WORKDIR /code
77
# set environment varibles
88
ENV PYTHONDONTWRITEBYTECODE=1
99
ENV PYTHONUNBUFFERED=1
10+
ENV DJANGO_TRAC_AUTH_SECRET=examplesecret
1011

1112
# getting postgres from PGDG (https://wiki.postgresql.org/wiki/Apt)
1213
# gnupg is required to run apt.postgresql.org.sh
@@ -31,6 +32,7 @@ RUN apt-get update \
3132
# install python dependencies
3233
COPY ./requirements.txt ./requirements.txt
3334
COPY ./DjangoPlugin ./DjangoPlugin
35+
COPY ./ExternalAuthPlugin ./ExternalAuthPlugin
3436

3537
RUN apt-get update \
3638
&& apt-get install --assume-yes --no-install-recommends \

ExternalAuthPlugin/setup.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from setuptools import find_packages, setup
2+
3+
setup(
4+
name="website-auth-plugin",
5+
version="1.0",
6+
packages=find_packages(),
7+
install_requires=[
8+
"Trac>=1.6",
9+
"PyJWT>=2,<3",
10+
],
11+
entry_points={
12+
"trac.plugins": ["tracdjangoprojectauth = tracdjangoprojectauth.plugins"]
13+
},
14+
)

ExternalAuthPlugin/tracdjangoprojectauth/__init__.py

Whitespace-only changes.
Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
"""External authentication bridge for code.djangoproject.com.
2+
3+
The companion djangoproject.com endpoint authenticates the user and returns a
4+
short-lived JWT assertion to ``/django-auth/callback``.
5+
"""
6+
7+
import os
8+
import secrets
9+
from urllib.parse import urlencode, urlsplit
10+
11+
import jwt
12+
from trac.core import implements
13+
from trac.util.html import html as tag
14+
from trac.web.api import IRequestHandler, HTTPMethodNotAllowed
15+
from trac.web.auth import LoginModule
16+
from trac.web.chrome import INavigationContributor, add_warning
17+
18+
19+
ASSERTION_ISSUER = "https://www.djangoproject.com/"
20+
ASSERTION_AUDIENCE = "code.djangoproject.com"
21+
SHARED_SECRET_ENV = "DJANGO_TRAC_AUTH_SECRET"
22+
CLOCK_SKEW = 10
23+
24+
25+
class InvalidAssertion(ValueError):
26+
"""Raised when an external authentication assertion is invalid."""
27+
28+
29+
def verify_assertion(
30+
token: str,
31+
*,
32+
secret: str,
33+
expected_state: str,
34+
) -> dict:
35+
"""Verify an authentication assertion issued by djangoproject.com."""
36+
37+
try:
38+
claims = jwt.decode(
39+
token,
40+
secret,
41+
algorithms=["HS256"],
42+
audience=ASSERTION_AUDIENCE,
43+
issuer=ASSERTION_ISSUER,
44+
leeway=CLOCK_SKEW,
45+
options={
46+
"require": [
47+
"sub",
48+
"aud",
49+
"iss",
50+
"iat",
51+
"exp",
52+
"state",
53+
],
54+
},
55+
)
56+
except jwt.PyJWTError as exc:
57+
raise InvalidAssertion("Invalid authentication assertion") from exc
58+
59+
subject = claims["sub"]
60+
if (
61+
not isinstance(subject, str)
62+
or not subject
63+
or subject == "anonymous"
64+
or any(character.isspace() for character in subject)
65+
):
66+
raise InvalidAssertion("Invalid subject")
67+
68+
state = claims["state"]
69+
if not isinstance(state, str) or state != expected_state:
70+
raise InvalidAssertion("Invalid state")
71+
72+
return claims
73+
74+
75+
class DjangoExternalLoginModule(LoginModule):
76+
"""Delegate authentication to djangoproject.com and create a Trac session."""
77+
78+
implements(IRequestHandler, INavigationContributor)
79+
80+
auth_url = "https://www.djangoproject.com/accounts/trac/login/"
81+
82+
login_path = "/django-auth/login"
83+
callback_path = "/django-auth/callback"
84+
logout_path = "/django-auth/logout"
85+
86+
state_session_key = "django_auth_state"
87+
next_session_key = "django_auth_next"
88+
89+
# INavigationContributor
90+
91+
def get_active_navigation_item(self, req):
92+
return "django_login"
93+
94+
def get_navigation_items(self, req):
95+
if req.authname and req.authname != "anonymous":
96+
yield (
97+
"metanav",
98+
"login",
99+
f"logged in as {req.authname}",
100+
)
101+
yield (
102+
"metanav",
103+
"logout",
104+
tag.form(
105+
tag.div(
106+
tag.input(
107+
type="hidden",
108+
name="__FORM_TOKEN",
109+
value=req.form_token,
110+
),
111+
tag.button(
112+
"Logout",
113+
name="logout",
114+
type="submit",
115+
),
116+
),
117+
action=req.href(self.logout_path),
118+
method="post",
119+
id="logout",
120+
class_="trac-logout",
121+
),
122+
)
123+
else:
124+
yield (
125+
"metanav",
126+
"django_login",
127+
tag.a(
128+
"Django account login",
129+
href=req.href(self.login_path),
130+
),
131+
)
132+
133+
# IRequestHandler
134+
135+
def match_request(self, req):
136+
return req.path_info in {
137+
self.login_path,
138+
self.callback_path,
139+
self.logout_path,
140+
}
141+
142+
def process_request(self, req):
143+
if req.path_info == self.login_path:
144+
return self._start_external_login(req)
145+
146+
if req.path_info == self.callback_path:
147+
return self._finish_external_login(req)
148+
149+
if req.path_info == self.logout_path:
150+
return self._logout(req)
151+
152+
raise AssertionError(f"Unexpected authentication path: {req.path_info}")
153+
154+
def _start_external_login(self, req):
155+
state = secrets.token_urlsafe(32)
156+
157+
req.session[self.state_session_key] = state
158+
req.session[self.next_session_key] = self._safe_local_path(
159+
req.args.get("referer") or req.get_header("Referer")
160+
)
161+
162+
callback_url = req.abs_href(self.callback_path.lstrip("/"))
163+
query = urlencode(
164+
{
165+
"state": state,
166+
"callback": callback_url,
167+
}
168+
)
169+
170+
req.redirect(f"{self.auth_url}?{query}")
171+
172+
def _finish_external_login(self, req):
173+
state = req.session.pop(self.state_session_key, None)
174+
if not state:
175+
self._reject(
176+
req,
177+
"Login session expired. Please try again.",
178+
)
179+
180+
token = req.args.get("assertion")
181+
if not token:
182+
self._reject(
183+
req,
184+
"The login response did not include an assertion.",
185+
)
186+
187+
secret = os.environ.get(SHARED_SECRET_ENV)
188+
if not secret:
189+
self.log.error(
190+
"Django authentication environment variable %s is not set",
191+
SHARED_SECRET_ENV,
192+
)
193+
self._reject(
194+
req,
195+
"Django account login is not configured.",
196+
)
197+
198+
try:
199+
claims = verify_assertion(
200+
token,
201+
secret=secret,
202+
expected_state=state,
203+
)
204+
except InvalidAssertion as exc:
205+
self.log.warning(
206+
"Rejected Django authentication assertion: %s",
207+
exc,
208+
)
209+
self._reject(
210+
req,
211+
"Invalid or expired login response. Please try again.",
212+
)
213+
214+
req.environ["REMOTE_USER"] = claims["sub"]
215+
216+
name = claims.get("name")
217+
if isinstance(name, str):
218+
req.session["name"] = name
219+
220+
email = claims.get("email")
221+
if isinstance(email, str):
222+
req.session["email"] = email
223+
224+
super()._do_login(req)
225+
226+
self._redirect_to_local_path(
227+
req,
228+
req.session.pop(self.next_session_key, None),
229+
)
230+
231+
def _logout(self, req):
232+
if req.method != "POST":
233+
raise HTTPMethodNotAllowed("Logout requires a POST request.")
234+
235+
self._do_logout(req)
236+
self._redirect_to_local_path(req, req.args.get("referer"))
237+
238+
def _reject(self, req, message):
239+
req.session.pop(self.next_session_key, None)
240+
add_warning(req, message)
241+
self._redirect_to_local_path(req, None)
242+
243+
def _redirect_to_local_path(self, req, candidate):
244+
req.redirect(req.abs_href(self._safe_local_path(candidate)))
245+
246+
@staticmethod
247+
def _safe_local_path(candidate):
248+
"""Return a local path, rejecting external redirect targets."""
249+
250+
if not candidate:
251+
return "/"
252+
253+
parsed = urlsplit(candidate)
254+
255+
if parsed.scheme or parsed.netloc:
256+
return "/"
257+
258+
if not parsed.path.startswith("/") or parsed.path.startswith("//"):
259+
return "/"
260+
261+
result = parsed.path
262+
if parsed.query:
263+
result += f"?{parsed.query}"
264+
265+
return result

0 commit comments

Comments
 (0)