Skip to content

Commit 17d7e37

Browse files
committed
Allow navigation back to calling app
1 parent 34b4547 commit 17d7e37

File tree

11 files changed

+218
-11
lines changed

11 files changed

+218
-11
lines changed

django/domains/iam/auth/adapters.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import logging
22
from smtplib import SMTPException
3+
from urllib.parse import urlparse
34

45
from django.http import HttpRequest
56
from django.conf import settings
@@ -9,6 +10,7 @@
910
from allauth.account.adapter import DefaultAccountAdapter, get_adapter as get_account_adapter
1011
from allauth.core import context as allauth_context
1112
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
13+
from interfaces.account.navigation import get_stored_account_return_to
1214
from interfaces.auth.schemas import AccountPatchBody
1315
from domains.iam.services import AccountService
1416

@@ -23,6 +25,32 @@ def get_logout_redirect_url(self, request: HttpRequest) -> str:
2325
def is_open_for_signup(self, request: HttpRequest):
2426
return settings.ACCOUNT_SIGNUP_ENABLED
2527

28+
def get_login_redirect_url(self, request):
29+
return get_stored_account_return_to(request) or super().get_login_redirect_url(
30+
request
31+
)
32+
33+
def get_signup_redirect_url(self, request):
34+
return get_stored_account_return_to(request) or super().get_signup_redirect_url(
35+
request
36+
)
37+
38+
def is_safe_url(self, url):
39+
parsed = urlparse(url)
40+
try:
41+
if super().is_safe_url(url):
42+
return True
43+
except (AttributeError, LookupError):
44+
if parsed.scheme or parsed.netloc:
45+
pass
46+
else:
47+
return url.startswith("/") and not url.startswith("//")
48+
49+
if not parsed.scheme or not parsed.netloc:
50+
return False
51+
52+
return self._origin_from_url(url) in self._get_allowed_return_origins()
53+
2654
def send_mail(self, template_prefix: str, email: str, context: dict) -> None:
2755
request = allauth_context.request
2856
ctx = {
@@ -46,6 +74,28 @@ def send_mail(self, template_prefix: str, email: str, context: dict) -> None:
4674
)
4775
console_connection.send_messages([msg])
4876

77+
def _get_allowed_return_origins(self):
78+
origins = set()
79+
80+
for client_config in getattr(settings, "OIDC_BUNDLED_CLIENTS", {}).values():
81+
for url in client_config.get("redirect_uris", []):
82+
origin = self._origin_from_url(url)
83+
if origin:
84+
origins.add(origin)
85+
for origin in client_config.get("cors_origins", []):
86+
normalized = self._origin_from_url(origin)
87+
if normalized:
88+
origins.add(normalized)
89+
90+
return origins
91+
92+
@staticmethod
93+
def _origin_from_url(url):
94+
parsed = urlparse(url)
95+
if not parsed.scheme or not parsed.netloc:
96+
return None
97+
return f"{parsed.scheme}://{parsed.netloc}"
98+
4999
def save_user(self, request, user, form, commit=True):
50100
user = super().save_user(request, user, form, commit=False)
51101
data = form.cleaned_data

django/hydroserver/settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@ def _url_origin(url):
212212
"django.template.context_processors.request",
213213
"django.contrib.auth.context_processors.auth",
214214
"django.contrib.messages.context_processors.messages",
215+
"interfaces.account.context_processors.account_navigation",
215216
],
216217
},
217218
},
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from interfaces.account.navigation import get_account_return_to
2+
3+
4+
def account_navigation(request):
5+
return {
6+
"account_return_url": get_account_return_to(request),
7+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
from urllib.parse import parse_qsl, unquote, urlencode, urlsplit, urlunsplit
2+
3+
from allauth.account.adapter import get_adapter as get_account_adapter
4+
from django.contrib.auth import REDIRECT_FIELD_NAME
5+
6+
7+
ACCOUNT_RETURN_TO_SESSION_KEY = "account_return_to"
8+
9+
10+
def normalize_account_return_to(request, value):
11+
if not isinstance(value, str):
12+
return None
13+
14+
normalized = unquote(value).strip()
15+
if not normalized:
16+
return None
17+
18+
if not get_account_adapter(request).is_safe_url(normalized):
19+
return None
20+
21+
return normalized
22+
23+
24+
def get_stored_account_return_to(request):
25+
session = getattr(request, "session", None)
26+
if session is None:
27+
return None
28+
return normalize_account_return_to(
29+
request,
30+
session.get(ACCOUNT_RETURN_TO_SESSION_KEY)
31+
)
32+
33+
34+
def get_account_return_to(request):
35+
for query_dict in [getattr(request, "POST", None), request.GET]:
36+
if not query_dict:
37+
continue
38+
value = normalize_account_return_to(request, query_dict.get(REDIRECT_FIELD_NAME))
39+
if value:
40+
session = getattr(request, "session", None)
41+
if session is not None:
42+
session[ACCOUNT_RETURN_TO_SESSION_KEY] = value
43+
return value
44+
45+
return get_stored_account_return_to(request)
46+
47+
48+
def with_account_return_to(url, return_to):
49+
normalized = return_to if isinstance(return_to, str) and return_to.strip() else None
50+
if not normalized:
51+
return url
52+
53+
parts = urlsplit(url)
54+
query = [
55+
(key, value)
56+
for key, value in parse_qsl(parts.query, keep_blank_values=True)
57+
if key != REDIRECT_FIELD_NAME
58+
]
59+
query.append((REDIRECT_FIELD_NAME, normalized))
60+
return urlunsplit(
61+
(parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment)
62+
)

django/interfaces/account/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
urlpatterns = [
55
path("login/", views.login, name="account_login"),
6+
path("signup/", views.signup, name="account_signup"),
67
path("email/", views.unavailable_account_management, name="disabled_account_email"),
78
path("3rdparty/", views.unavailable_account_management, name="disabled_socialaccount_connections"),
89
path("social/connections/", views.unavailable_account_management, name="disabled_socialaccount_connections_legacy"),

django/interfaces/account/views.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import logging
22
from urllib.parse import unquote
33

4-
from allauth.account.views import LoginView as AllauthLoginView
4+
from allauth.account.views import LoginView as AllauthLoginView, SignupView as AllauthSignupView
55
from django.contrib.auth import REDIRECT_FIELD_NAME
66
from django.contrib.auth import logout
77
from django.contrib.auth.decorators import login_required
88
from django.db import OperationalError, ProgrammingError
99
from django.shortcuts import render, redirect
1010
from django.contrib import messages
1111
from interfaces.account.forms import ProfileForm, DeleteAccountForm
12+
from interfaces.account.navigation import get_account_return_to
1213
from domains.iam.services import AccountService
1314

1415

@@ -38,23 +39,37 @@ def dispatch(self, request, *args, **kwargs):
3839
request.GET = _normalize_redirect_field(request.GET)
3940
if request.method == "POST":
4041
request.POST = _normalize_redirect_field(request.POST)
42+
get_account_return_to(request)
43+
return super().dispatch(request, *args, **kwargs)
44+
45+
46+
class SignupView(AllauthSignupView):
47+
def dispatch(self, request, *args, **kwargs):
48+
request.GET = _normalize_redirect_field(request.GET)
49+
if request.method == "POST":
50+
request.POST = _normalize_redirect_field(request.POST)
51+
get_account_return_to(request)
4152
return super().dispatch(request, *args, **kwargs)
4253

4354

4455
login = LoginView.as_view()
56+
signup = SignupView.as_view()
4557

4658

4759
@login_required
4860
def profile(request):
4961
user = request.user
62+
return_to = get_account_return_to(request)
5063
return render(request, "account/profile.html", {
5164
"user": user,
5265
"organization": user.organization,
66+
"return_to": return_to,
5367
})
5468

5569

5670
@login_required
5771
def profile_edit(request):
72+
return_to = get_account_return_to(request)
5873
if request.method == "POST":
5974
form = ProfileForm(request.POST, instance=request.user)
6075
if form.is_valid():
@@ -64,18 +79,20 @@ def profile_edit(request):
6479
else:
6580
form = ProfileForm(instance=request.user)
6681

67-
return render(request, "account/profile_edit.html", {"form": form})
82+
return render(request, "account/profile_edit.html", {"form": form, "return_to": return_to})
6883

6984

7085
@login_required
7186
def unavailable_account_management(request):
87+
get_account_return_to(request)
7288
messages.info(request, "That account management page is not available in this HydroServer instance.")
7389
return redirect("account_profile")
7490

7591

7692
@login_required
7793
def delete_account(request):
7894
user = request.user
95+
return_to = get_account_return_to(request)
7996
owned_workspaces = []
8097
try:
8198
from domains.iam.models import Workspace
@@ -99,4 +116,5 @@ def delete_account(request):
99116
return render(request, "account/delete_account.html", {
100117
"form": form,
101118
"owned_workspaces": owned_workspaces,
119+
"return_to": return_to,
102120
})

django/templates/account/profile.html

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,21 @@ <h2 class="text-lg font-medium text-gray-900">
2626
<p class="text-sm text-gray-600">{{ user.email }}</p>
2727
</div>
2828
</div>
29-
<a href="{% url 'account_profile_edit' %}"
30-
class="inline-flex min-h-12 items-center justify-center rounded-xl border border-primary bg-white px-5 py-3 text-sm font-medium text-primary transition-colors hover:bg-primary-light hover:text-primary-dark">
31-
{% trans "Edit profile" %}
32-
</a>
29+
<div class="flex flex-col gap-3 sm:flex-row">
30+
{% if account_return_url %}
31+
<a href="{{ account_return_url }}"
32+
class="inline-flex min-h-12 items-center justify-center gap-2 rounded-xl border border-primary bg-primary px-5 py-3 text-sm font-medium text-white shadow-elevation-2 transition-colors hover:bg-primary-dark">
33+
<svg class="h-4 w-4 shrink-0" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
34+
<path fill-rule="evenodd" d="M17.5 10a.75.75 0 0 1-.75.75H5.06l3.22 3.22a.75.75 0 1 1-1.06 1.06l-4.5-4.5a.75.75 0 0 1 0-1.06l4.5-4.5a.75.75 0 1 1 1.06 1.06L5.06 9.25h11.69a.75.75 0 0 1 .75.75Z" clip-rule="evenodd" />
35+
</svg>
36+
{% trans "Back to app" %}
37+
</a>
38+
{% endif %}
39+
<a href="{% url 'account_profile_edit' %}"
40+
class="inline-flex min-h-12 items-center justify-center rounded-xl border border-primary bg-white px-5 py-3 text-sm font-medium text-primary transition-colors hover:bg-primary-light hover:text-primary-dark">
41+
{% trans "Edit profile" %}
42+
</a>
43+
</div>
3344
</div>
3445

3546
<section class="space-y-4 rounded-xl border border-gray-200 bg-white p-5">

django/tests/iam/test_account_adapter.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,18 @@ def test_account_adapter_reraises_email_delivery_errors_outside_dev(
5555
with request_context(request):
5656
with pytest.raises(SMTPException):
5757
adapter.send_mail("account/email/email_confirmation", "user@example.com", {})
58+
59+
60+
def test_account_adapter_allows_registered_client_origins_as_return_urls(settings):
61+
adapter = AccountAdapter()
62+
63+
settings.OIDC_BUNDLED_CLIENTS = {
64+
"test": {
65+
"id": "test-client",
66+
"redirect_uris": ["http://127.0.0.1:5173/callback"],
67+
"cors_origins": ["http://127.0.0.1:5173"],
68+
}
69+
}
70+
71+
assert adapter.is_safe_url("http://127.0.0.1:5173/orchestration?workspaceId=123")
72+
assert not adapter.is_safe_url("http://malicious.example.com/orchestration")

django/tests/iam/test_auth_profile_flow.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,19 @@ def test_email_verification_sent_page_renders():
220220
assert "Verify your email" in content
221221

222222

223+
def test_profile_page_shows_back_to_app_link(client, get_principal):
224+
client.force_login(get_principal("owner"))
225+
226+
response = client.get(
227+
"/accounts/profile/",
228+
{"next": "http://127.0.0.1:5173/orchestration?workspaceId=abc123"},
229+
)
230+
231+
assert response.status_code == 200
232+
assert b"Back to app" in response.content
233+
assert b"http://127.0.0.1:5173/orchestration?workspaceId=abc123" in response.content
234+
235+
223236
def test_account_signup_requires_first_name():
224237
payload = signup_payload(f"signup-missing-first-{uuid4().hex}@example.com")
225238
payload["firstName"] = ""

packages/hydroserver-ts/src/api/__tests__/session.service.spec.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,4 +130,19 @@ describe('SessionService', () => {
130130

131131
expect(() => session.checkExpiration()).not.toThrow()
132132
})
133+
134+
it('builds account urls with the current app route as next', () => {
135+
window.history.replaceState({}, '', '/orchestration?workspaceId=abc#runs')
136+
137+
const session = new SessionService(
138+
new HydroServer({ host: 'https://hydro.example.com' })
139+
)
140+
141+
expect(session.accountSignupUrl).toBe(
142+
'https://hydro.example.com/accounts/signup/?next=http%3A%2F%2Flocalhost%3A3000%2Forchestration%3FworkspaceId%3Dabc%23runs'
143+
)
144+
expect(session.accountProfileUrl).toBe(
145+
'https://hydro.example.com/accounts/profile/?next=http%3A%2F%2Flocalhost%3A3000%2Forchestration%3FworkspaceId%3Dabc%23runs'
146+
)
147+
})
133148
})

0 commit comments

Comments
 (0)