Skip to content

Commit f7f1e97

Browse files
committed
Add support for RP-Initiated Registration
By adding a `prompt=create` parameter to the authorization request, the user is redirected to the OP's registration point where they can create an account, and on successful registration the user is then redirected back to the authorization view with prompt=login Closes #1546
1 parent db4c6c7 commit f7f1e97

File tree

8 files changed

+185
-3
lines changed

8 files changed

+185
-3
lines changed

AUTHORS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ Peter Karman
9999
Peter McDonald
100100
Petr Dlouhý
101101
pySilver
102+
Raphael Lullis
102103
Rodney Richardson
103104
Rustem Saiargaliev
104105
Rustem Saiargaliev

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [unreleased]
99
### Added
1010
* #1506 Support for Wildcard Origin and Redirect URIs
11+
* #1546 Support for RP-Initiated Registration
1112
<!--
1213
### Changed
1314
### Deprecated

docs/settings.rst

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,7 @@ this you must also provide the service at that endpoint.
353353
If unset, the default location is used, eg if ``django-oauth-toolkit`` is
354354
mounted at ``/o/``, it will be ``<server-address>/o/userinfo/``.
355355

356+
356357
OIDC_RP_INITIATED_LOGOUT_ENABLED
357358
~~~~~~~~~~~~~~~~~~~~~~~~
358359
Default: ``False``
@@ -388,6 +389,24 @@ Whether to delete the access, refresh and ID tokens of the user that is being lo
388389
The types of applications for which tokens are deleted can be customized with ``RPInitiatedLogoutView.token_types_to_delete``.
389390
The default is to delete the tokens of all applications if this flag is enabled.
390391

392+
OIDC_RP_INITIATED_REGISTRATION_ENABLED
393+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
394+
Default: ``False``
395+
396+
Whether to allow the Relying Party (RP) to direct a user to an OpenID
397+
Provider (OP) to create a new account rather than authenticate with an
398+
existing one. This is done by adding a `prompt=create` parameter to
399+
the authorization request.
400+
401+
OIDC_RP_INITIATED_REGISTRATION_VIEW_NAME
402+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
403+
Default: ''
404+
405+
The name of the view for the URL that the user will be redirected to
406+
in case RP-Initated Registration is enabled.
407+
408+
409+
391410
OIDC_ISS_ENDPOINT
392411
~~~~~~~~~~~~~~~~~
393412
Default: ``""``

oauth2_provider/settings.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@
9292
"client_secret_post",
9393
"client_secret_basic",
9494
],
95+
"OIDC_RP_INITIATED_REGISTRATION_ENABLED": False,
96+
"OIDC_RP_INITIATED_REGISTRATION_VIEW_NAME": None,
9597
"OIDC_RP_INITIATED_LOGOUT_ENABLED": False,
9698
"OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT": True,
9799
"OIDC_RP_INITIATED_LOGOUT_STRICT_REDIRECT_URIS": False,

oauth2_provider/views/base.py

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import hashlib
22
import json
33
import logging
4-
from urllib.parse import parse_qsl, urlencode, urlparse
4+
from urllib.parse import parse_qsl, quote, urlencode, urlparse
55

66
from django.contrib.auth.mixins import LoginRequiredMixin
77
from django.contrib.auth.views import redirect_to_login
8-
from django.http import HttpResponse
8+
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseRedirect
99
from django.shortcuts import resolve_url
10+
from django.urls import reverse
11+
from django.urls.exceptions import NoReverseMatch
1012
from django.utils import timezone
1113
from django.utils.decorators import method_decorator
1214
from django.views.decorators.csrf import csrf_exempt
@@ -154,6 +156,8 @@ def get(self, request, *args, **kwargs):
154156
prompt = request.GET.get("prompt")
155157
if prompt == "login":
156158
return self.handle_prompt_login()
159+
elif prompt == "create":
160+
return self.handle_prompt_create()
157161

158162
all_scopes = get_scopes_backend().get_all_scopes()
159163
kwargs["scopes_descriptions"] = [all_scopes[scope] for scope in scopes]
@@ -252,13 +256,72 @@ def handle_prompt_login(self):
252256
self.get_redirect_field_name(),
253257
)
254258

259+
def handle_prompt_create(self):
260+
"""
261+
When prompt=create is in the authorization request,
262+
redirect the user to the registration page. After
263+
registration, the user should be redirected back to the
264+
authorization endpoint without the prompt parameter to
265+
continue the OIDC flow.
266+
267+
Implements OpenID Connect Prompt Create 1.0 specification.
268+
https://openid.net/specs/openid-connect-prompt-create-1_0.html
269+
270+
"""
271+
try:
272+
assert not self.request.user.is_authenticated, "account_selection_required"
273+
path = self.request.build_absolute_uri()
274+
275+
views_to_attempt = [oauth2_settings.OIDC_RP_INITIATED_REGISTRATION_VIEW_NAME, "account_signup"]
276+
277+
registration_url = None
278+
for view_name in views_to_attempt:
279+
try:
280+
registration_url = reverse(view_name)
281+
continue
282+
except NoReverseMatch:
283+
pass
284+
285+
# Parse the current URL and remove the prompt parameter
286+
parsed = urlparse(path)
287+
parsed_query = dict(parse_qsl(parsed.query))
288+
parsed_query.pop("prompt")
289+
290+
# Create the next parameter to redirect back to the authorization endpoint
291+
next_url = parsed._replace(query=urlencode(parsed_query)).geturl()
292+
293+
assert oauth2_settings.OIDC_RP_INITIATED_REGISTRATION_ENABLED, "access_denied"
294+
assert registration_url is not None, "access_denied"
295+
296+
# Add next parameter to registration URL
297+
separator = "&" if "?" in registration_url else "?"
298+
redirect_to = f"{registration_url}{separator}next={quote(next_url)}"
299+
300+
return HttpResponseRedirect(redirect_to)
301+
302+
except AssertionError as exc:
303+
redirect_uri = self.request.GET.get("redirect_uri")
304+
if redirect_uri:
305+
response_parameters = {"error": str(exc)}
306+
state = self.request.GET.get("state")
307+
if state:
308+
response_parameters["state"] = state
309+
310+
separator = "&" if "?" in redirect_uri else "?"
311+
redirect_to = redirect_uri + separator + urlencode(response_parameters)
312+
return self.redirect(redirect_to, application=None)
313+
else:
314+
return HttpResponseBadRequest(str(exc))
315+
255316
def handle_no_permission(self):
256317
"""
257318
Generate response for unauthorized users.
258319
259320
If prompt is set to none, then we redirect with an error code
260321
as defined by OIDC 3.1.2.6
261322
323+
If prompt is set to create, then we redirect to the registration page.
324+
262325
Some code copied from OAuthLibMixin.error_response, but that is designed
263326
to operated on OAuth1Error from oauthlib wrapped in a OAuthToolkitError
264327
"""
@@ -276,6 +339,9 @@ def handle_no_permission(self):
276339
separator = "&" if "?" in redirect_uri else "?"
277340
redirect_to = redirect_uri + separator + urlencode(response_parameters)
278341
return self.redirect(redirect_to, application=None)
342+
elif prompt == "create":
343+
# If prompt=create and user is not authenticated, redirect to registration
344+
return self.handle_prompt_create()
279345
else:
280346
return super().handle_no_permission()
281347

oauth2_provider/views/oidc.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,11 @@ def get(self, request, *args, **kwargs):
100100
),
101101
"code_challenge_methods_supported": [key for key, _ in AbstractGrant.CODE_CHALLENGE_METHODS],
102102
"claims_supported": oidc_claims,
103+
"prompt_values_supported": ["none", "login"],
103104
}
105+
if oauth2_settings.OIDC_RP_INITIATED_REGISTRATION_ENABLED:
106+
data["prompt_values_supported"].append("create")
107+
104108
if oauth2_settings.OIDC_RP_INITIATED_LOGOUT_ENABLED:
105109
data["end_session_endpoint"] = end_session_endpoint
106110
response = JsonResponse(data)

tests/presets.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@
3737
OIDC_SETTINGS_RP_LOGOUT_DENY_EXPIRED["OIDC_RP_INITIATED_LOGOUT_ACCEPT_EXPIRED_TOKENS"] = False
3838
OIDC_SETTINGS_RP_LOGOUT_KEEP_TOKENS = deepcopy(OIDC_SETTINGS_RP_LOGOUT)
3939
OIDC_SETTINGS_RP_LOGOUT_KEEP_TOKENS["OIDC_RP_INITIATED_LOGOUT_DELETE_TOKENS"] = False
40+
OIDC_SETTINGS_RP_REGISTRATION = deepcopy(OIDC_SETTINGS_RW)
41+
OIDC_SETTINGS_RP_REGISTRATION["OIDC_RP_INITIATED_REGISTRATION_ENABLED"] = True
42+
OIDC_SETTINGS_RP_REGISTRATION["OIDC_RP_INITIATED_REGISTRATION_VIEW_NAME"] = "testapp:register"
43+
4044
REST_FRAMEWORK_SCOPES = {
4145
"SCOPES": {
4246
"read": "Read scope",

tests/test_oidc_views.py

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
from unittest.mock import patch
2+
from urllib.parse import parse_qs, urlparse
3+
14
import pytest
25
from django.contrib.auth import get_user
36
from django.contrib.auth.models import AnonymousUser
@@ -12,7 +15,12 @@
1215
InvalidOIDCClientError,
1316
InvalidOIDCRedirectURIError,
1417
)
15-
from oauth2_provider.models import get_access_token_model, get_id_token_model, get_refresh_token_model
18+
from oauth2_provider.models import (
19+
get_access_token_model,
20+
get_application_model,
21+
get_id_token_model,
22+
get_refresh_token_model,
23+
)
1624
from oauth2_provider.oauth2_validators import OAuth2Validator
1725
from oauth2_provider.settings import oauth2_settings
1826
from oauth2_provider.views.oidc import RPInitiatedLogoutView, _load_id_token, _validate_claims
@@ -47,6 +55,7 @@ def test_get_connect_discovery_info(self):
4755
"token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"],
4856
"code_challenge_methods_supported": ["plain", "S256"],
4957
"claims_supported": ["sub"],
58+
"prompt_values_supported": ["none", "login"],
5059
}
5160
response = self.client.get("/o/.well-known/openid-configuration")
5261
self.assertEqual(response.status_code, 200)
@@ -74,6 +83,7 @@ def test_get_connect_discovery_info_deprecated(self):
7483
"token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"],
7584
"code_challenge_methods_supported": ["plain", "S256"],
7685
"claims_supported": ["sub"],
86+
"prompt_values_supported": ["none", "login"],
7787
}
7888
response = self.client.get("/o/.well-known/openid-configuration/")
7989
self.assertEqual(response.status_code, 200)
@@ -101,6 +111,7 @@ def expect_json_response_with_rp_logout(self, base):
101111
"token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"],
102112
"code_challenge_methods_supported": ["plain", "S256"],
103113
"claims_supported": ["sub"],
114+
"prompt_values_supported": ["none", "login"],
104115
"end_session_endpoint": f"{base}/logout/",
105116
}
106117
response = self.client.get(reverse("oauth2_provider:oidc-connect-discovery-info"))
@@ -135,6 +146,7 @@ def test_get_connect_discovery_info_without_issuer_url(self):
135146
"token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"],
136147
"code_challenge_methods_supported": ["plain", "S256"],
137148
"claims_supported": ["sub"],
149+
"prompt_values_supported": ["none", "login"],
138150
}
139151
response = self.client.get(reverse("oauth2_provider:oidc-connect-discovery-info"))
140152
self.assertEqual(response.status_code, 200)
@@ -206,6 +218,79 @@ def test_get_jwks_info_multiple_rsa_keys(self):
206218
assert response.json() == expected_response
207219

208220

221+
@pytest.mark.usefixtures("oauth2_settings")
222+
@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RP_REGISTRATION)
223+
class TestRPInitiatedRegistration(TestCase):
224+
def test_connect_discovery_info_has_create(self):
225+
expected_response = {
226+
"issuer": "http://localhost/o",
227+
"authorization_endpoint": "http://localhost/o/authorize/",
228+
"token_endpoint": "http://localhost/o/token/",
229+
"userinfo_endpoint": "http://localhost/o/userinfo/",
230+
"jwks_uri": "http://localhost/o/.well-known/jwks.json",
231+
"scopes_supported": ["read", "write", "openid"],
232+
"response_types_supported": [
233+
"code",
234+
"token",
235+
"id_token",
236+
"id_token token",
237+
"code token",
238+
"code id_token",
239+
"code id_token token",
240+
],
241+
"subject_types_supported": ["public"],
242+
"id_token_signing_alg_values_supported": ["RS256", "HS256"],
243+
"token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"],
244+
"code_challenge_methods_supported": ["plain", "S256"],
245+
"claims_supported": ["sub"],
246+
"prompt_values_supported": ["none", "login", "create"],
247+
}
248+
response = self.client.get("/o/.well-known/openid-configuration")
249+
self.assertEqual(response.status_code, 200)
250+
assert response.json() == expected_response
251+
252+
def test_prompt_create_redirects_to_registration_view(self):
253+
Application = get_application_model()
254+
application = Application.objects.create(
255+
name="Test Application",
256+
redirect_uris="http://localhost http://example.com",
257+
client_type=Application.CLIENT_CONFIDENTIAL,
258+
authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE,
259+
)
260+
261+
auth_url = reverse("oauth2_provider:authorize")
262+
query_params = {
263+
"response_type": "code",
264+
"client_id": application.client_id,
265+
"redirect_uri": "http://localhost",
266+
"scope": "openid",
267+
"prompt": "create",
268+
}
269+
270+
with patch("oauth2_provider.views.base.reverse") as patched_reverse:
271+
patched_reverse.return_value = "/register-test/"
272+
response = self.client.get(f"{auth_url}?{'&'.join(f'{k}={v}' for k, v in query_params.items())}")
273+
274+
self.assertEqual(response.status_code, 302)
275+
redirect_url = response.url
276+
parsed_url = urlparse(redirect_url)
277+
278+
# Verify it's the registration URL
279+
self.assertEqual(parsed_url.path, "/register-test/")
280+
281+
# Verify the query parameters
282+
query = parse_qs(parsed_url.query)
283+
self.assertIn("next", query)
284+
285+
# Verify the next parameter doesn't contain prompt=create
286+
next_url = query["next"][0]
287+
self.assertNotIn("prompt=create", next_url)
288+
289+
# But it should contain the other original parameters
290+
self.assertIn("response_type=code", next_url)
291+
self.assertIn(f"client_id={application.client_id}", next_url)
292+
293+
209294
def mock_request():
210295
"""
211296
Dummy request with an AnonymousUser attached.

0 commit comments

Comments
 (0)