Rendering the SSO buttons as POST forms (#23042) made every SSO login a form submission which NetBox answers with a redirect to the identity provider. Chromium-based browsers evaluate the CSP form-action directive against every hop in a form submission's redirect chain, so a deployment which serves NetBox with `form-action 'self'` blocks that redirect and the button silently does nothing. Add SocialAuthBeginView, which wraps python-social-auth's begin view and returns the identity provider's URL as JSON to clients which request it. The login page now submits the form via fetch() and assigns window.location, which form-action does not govern. The upstream view is reused as-is, so CSRF protection, the callback URL, and the session state recorded for the identity provider are unchanged; clients which do not request JSON (a browser without JavaScript, or a backend which renders an HTML form rather than redirecting) receive the unmodified response as before. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e15b3d9080
commit
a4ade2e7ae
|
|
@ -41,6 +41,12 @@ NetBox supports single sign-on authentication via the [python-social-auth](https
|
|||
|
||||
Most remote authentication backends require some additional configuration through settings prefixed with `SOCIAL_AUTH_`. These will be automatically imported from NetBox's `configuration.py` file. Additionally, the [authentication pipeline](https://python-social-auth.readthedocs.io/en/latest/pipeline.html) can be customized via the `SOCIAL_AUTH_PIPELINE` parameter. (NetBox's default pipeline is defined in `netbox/settings.py` for your reference.)
|
||||
|
||||
!!! note "Content Security Policy"
|
||||
Beginning an SSO login requires the browser to make a request back to NetBox before it is sent
|
||||
on to the identity provider. If you serve NetBox with a Content Security Policy which does not
|
||||
permit same-origin connections, SSO logins will fail: add `connect-src 'self'` (or a
|
||||
`default-src` which covers it) to your policy.
|
||||
|
||||
#### Configuring the SSO module's appearance
|
||||
|
||||
The way a remote authentication backend is displayed to the user on the login
|
||||
|
|
|
|||
|
|
@ -9,14 +9,16 @@ from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm
|
|||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.contrib.auth.models import update_last_login
|
||||
from django.contrib.auth.signals import user_logged_in
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.http import HttpResponseRedirect, JsonResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render, resolve_url
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.decorators.cache import never_cache
|
||||
from django.views.decorators.debug import sensitive_post_parameters
|
||||
from django.views.generic import View
|
||||
from social_core.backends.utils import load_backends
|
||||
from social_django.views import auth as social_auth_begin
|
||||
|
||||
from account.models import UserToken
|
||||
from core.models import ObjectChange
|
||||
|
|
@ -78,7 +80,7 @@ class LoginView(View):
|
|||
request_data = request.POST if request.method == 'POST' else request.GET
|
||||
|
||||
for name in load_backends(settings.AUTHENTICATION_BACKENDS).keys():
|
||||
url = reverse('social:begin', args=[name])
|
||||
url = reverse('social_auth_begin', args=[name])
|
||||
params = {}
|
||||
if next := request_data.get('next'):
|
||||
params['next'] = next
|
||||
|
|
@ -188,6 +190,50 @@ class LogoutView(View):
|
|||
return response
|
||||
|
||||
|
||||
class SocialAuthBeginView(View):
|
||||
"""
|
||||
Initiate authentication against a social auth (SSO) backend.
|
||||
|
||||
This wraps python-social-auth's "begin" view, which responds with an HTTP redirect to the
|
||||
identity provider. Chromium-based browsers evaluate the CSP `form-action` directive against
|
||||
every hop in a form submission's redirect chain, so a deployment which serves NetBox with
|
||||
`form-action 'self'` (a common reverse proxy default) blocks that redirect and the SSO button
|
||||
appears to do nothing. A client which asks for JSON is given the identity provider's URL in the
|
||||
response body instead, and navigates to it itself: `form-action` does not govern a navigation
|
||||
initiated by a script. Any other client (e.g. a browser with JavaScript disabled) receives the
|
||||
unmodified response from python-social-auth.
|
||||
|
||||
A backend which does not redirect (`uses_redirect()` is False, as for OpenID 2.0) renders its
|
||||
own HTML instead, which is returned in the response body for the client to render in place so
|
||||
that it need not repeat the request. This is not a way around `form-action`: that document
|
||||
carries a form which submits itself to the identity provider, and such a submission is governed
|
||||
by the policy wherever the document is rendered. Deployments using one of these backends still
|
||||
require a `form-action` which admits the identity provider.
|
||||
|
||||
The underlying view is reused as-is so that CSRF protection, the callback URL, and the session
|
||||
state recorded for the identity provider all remain identical to a direct form submission.
|
||||
"""
|
||||
@method_decorator(never_cache)
|
||||
def dispatch(self, *args, **kwargs):
|
||||
return super().dispatch(*args, **kwargs)
|
||||
|
||||
def post(self, request, backend):
|
||||
response = social_auth_begin(request, backend)
|
||||
|
||||
if 'application/json' in request.headers.get('Accept', ''):
|
||||
if url := response.headers.get('Location'):
|
||||
return JsonResponse({'url': url})
|
||||
if response.status_code == 200:
|
||||
# Some backends render an HTML form (which submits itself to the identity provider)
|
||||
# rather than redirecting. Hand that document to the client to render, so that it
|
||||
# need not repeat the request and initiate the login a second time.
|
||||
return JsonResponse({'html': response.content.decode(response.charset)})
|
||||
|
||||
# Anything else (including the response to a client which has not asked for JSON) is passed
|
||||
# through unchanged.
|
||||
return response
|
||||
|
||||
|
||||
#
|
||||
# User profiles
|
||||
#
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from unittest.mock import MagicMock, patch
|
|||
|
||||
from django.conf import settings
|
||||
from django.contrib.messages.storage.fallback import FallbackStorage
|
||||
from django.http import HttpResponse
|
||||
from django.test import Client, RequestFactory, SimpleTestCase
|
||||
from django.test import TestCase as DjangoTestCase
|
||||
from django.test.utils import override_settings
|
||||
|
|
@ -861,7 +862,7 @@ class SSOLoginButtonTestCase(DjangoTestCase):
|
|||
Return the body of the rendered SSO form. The password login form renders its own hidden
|
||||
`next` field, so assertions about the SSO parameters must be scoped to this form.
|
||||
"""
|
||||
begin_url = reverse('social:begin', args=['google-oauth2'])
|
||||
begin_url = reverse('social_auth_begin', args=['google-oauth2'])
|
||||
match = re.search(
|
||||
rf'<form[^>]*action="{re.escape(begin_url)}"[^>]*>(.*?)</form>',
|
||||
response.content.decode(),
|
||||
|
|
@ -876,7 +877,7 @@ class SSOLoginButtonTestCase(DjangoTestCase):
|
|||
"""
|
||||
Each SSO button must be rendered as a POST form (including a CSRF token) rather than a link.
|
||||
"""
|
||||
begin_url = reverse('social:begin', args=['google-oauth2'])
|
||||
begin_url = reverse('social_auth_begin', args=['google-oauth2'])
|
||||
response = self.client.get(reverse('login'))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
|
@ -951,6 +952,158 @@ class SSOLoginButtonTestCase(DjangoTestCase):
|
|||
self.assertEqual(auth_backend['params'].get('next'), '/dcim/sites/')
|
||||
|
||||
|
||||
class SocialAuthBeginViewTestCase(DjangoTestCase):
|
||||
"""
|
||||
Verify the view which initiates an SSO login. Chromium-based browsers evaluate the CSP
|
||||
`form-action` directive against every hop in a form submission's redirect chain, so redirecting
|
||||
the submission to the identity provider is blocked wherever `form-action 'self'` is enforced.
|
||||
Clients which ask for JSON are handed the identity provider's URL to navigate to instead
|
||||
(see #23112).
|
||||
"""
|
||||
SSO_BACKENDS = [
|
||||
'social_core.backends.google.GoogleOAuth2',
|
||||
'netbox.authentication.ObjectPermissionBackend',
|
||||
]
|
||||
AUTHORIZATION_URL = 'https://accounts.google.com/o/oauth2/auth'
|
||||
# Stands in for the document rendered by a backend which does not redirect (see BaseAuth.start())
|
||||
AUTH_HTML = '<html><body><form id="openid_message" action="https://idp.example.com/"></form></body></html>'
|
||||
|
||||
def setUp(self):
|
||||
# load_backends() caches the discovered backends in a module-level dict, so isolate the
|
||||
# backends overridden below from the remainder of the test suite.
|
||||
cache_patcher = patch.dict('social_core.backends.utils.BACKENDSCACHE', {}, clear=True)
|
||||
cache_patcher.start()
|
||||
self.addCleanup(cache_patcher.stop)
|
||||
|
||||
self.url = reverse('social_auth_begin', args=['google-oauth2'])
|
||||
|
||||
@override_settings(AUTHENTICATION_BACKENDS=SSO_BACKENDS)
|
||||
def test_json_request_returns_authorization_url(self):
|
||||
"""
|
||||
A client which requests JSON receives the identity provider's URL in the response body
|
||||
rather than an HTTP redirect, so that it can navigate there itself.
|
||||
"""
|
||||
response = self.client.post(self.url, headers={'accept': 'application/json'})
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.headers['Content-Type'], 'application/json')
|
||||
self.assertNotIn('Location', response.headers)
|
||||
self.assertTrue(response.json()['url'].startswith(self.AUTHORIZATION_URL))
|
||||
|
||||
@override_settings(AUTHENTICATION_BACKENDS=SSO_BACKENDS)
|
||||
def test_form_submission_returns_redirect(self):
|
||||
"""
|
||||
A client which has not asked for JSON (e.g. a browser with JavaScript disabled) receives the
|
||||
unmodified redirect from python-social-auth.
|
||||
"""
|
||||
response = self.client.post(self.url, headers={'accept': 'text/html'})
|
||||
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertTrue(response.headers['Location'].startswith(self.AUTHORIZATION_URL))
|
||||
|
||||
@override_settings(AUTHENTICATION_BACKENDS=SSO_BACKENDS)
|
||||
def test_session_state_recorded(self):
|
||||
"""
|
||||
The anti-forgery state conveyed to the identity provider must be recorded in the session, as
|
||||
the completion view compares the two. This is what makes the JSON response safe to follow:
|
||||
the session established here is the one the callback is validated against.
|
||||
"""
|
||||
response = self.client.post(self.url, headers={'accept': 'application/json'})
|
||||
|
||||
state = self.client.session['google-oauth2_state']
|
||||
self.assertIn(f'state={state}', response.json()['url'])
|
||||
|
||||
@override_settings(AUTHENTICATION_BACKENDS=SSO_BACKENDS)
|
||||
def test_next_recorded_in_session(self):
|
||||
"""
|
||||
The post-login URL is read from the POST data by do_auth() and stashed in the session; the
|
||||
wrapper must not interfere with the form fields rendered on the login page.
|
||||
"""
|
||||
self.client.post(self.url, {'next': '/dcim/sites/'}, headers={'accept': 'application/json'})
|
||||
|
||||
self.assertEqual(self.client.session['next'], '/dcim/sites/')
|
||||
|
||||
@override_settings(AUTHENTICATION_BACKENDS=SSO_BACKENDS)
|
||||
def test_get_request_not_allowed(self):
|
||||
"""
|
||||
Authentication must be initiated by POST: a GET request is trivially forgeable, which is why
|
||||
social-auth-app-django restricts its own begin view to POST.
|
||||
"""
|
||||
response = self.client.get(self.url)
|
||||
|
||||
self.assertEqual(response.status_code, 405)
|
||||
|
||||
@override_settings(AUTHENTICATION_BACKENDS=SSO_BACKENDS)
|
||||
def test_csrf_token_required(self):
|
||||
"""
|
||||
CSRF protection must be retained, so that a third party cannot silently initiate an SSO
|
||||
login on the user's behalf.
|
||||
"""
|
||||
client = Client(enforce_csrf_checks=True)
|
||||
response = client.post(self.url, headers={'accept': 'application/json'})
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
@override_settings(AUTHENTICATION_BACKENDS=SSO_BACKENDS)
|
||||
def test_response_is_not_cached(self):
|
||||
"""
|
||||
The authorization URL embeds a single-use state parameter and must never be cached.
|
||||
"""
|
||||
response = self.client.post(self.url, headers={'accept': 'application/json'})
|
||||
|
||||
self.assertIn('no-store', response.headers['Cache-Control'])
|
||||
|
||||
@override_settings(AUTHENTICATION_BACKENDS=SSO_BACKENDS)
|
||||
def test_unknown_backend(self):
|
||||
"""
|
||||
An unconfigured backend yields an HTTP 404, as it does via python-social-auth directly.
|
||||
"""
|
||||
response = self.client.post(reverse('social_auth_begin', args=['nosuchbackend']))
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
@override_settings(AUTHENTICATION_BACKENDS=SSO_BACKENDS)
|
||||
@patch('social_core.backends.google.GoogleOAuth2.uses_redirect', return_value=False)
|
||||
@patch('social_core.backends.google.GoogleOAuth2.auth_html', return_value=AUTH_HTML)
|
||||
def test_json_request_returns_html_for_non_redirecting_backend(self, _auth_html, _uses_redirect):
|
||||
"""
|
||||
A backend which renders its own HTML rather than redirecting has that document returned in
|
||||
the response body. The client renders it in place: were it made to submit the form to fetch
|
||||
the document again, the login would be initiated a second time.
|
||||
"""
|
||||
response = self.client.post(self.url, headers={'accept': 'application/json'})
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.headers['Content-Type'], 'application/json')
|
||||
self.assertNotIn('Location', response.headers)
|
||||
self.assertEqual(response.json()['html'], self.AUTH_HTML)
|
||||
|
||||
@override_settings(AUTHENTICATION_BACKENDS=SSO_BACKENDS)
|
||||
@patch('social_core.backends.google.GoogleOAuth2.uses_redirect', return_value=False)
|
||||
@patch('social_core.backends.google.GoogleOAuth2.auth_html', return_value=AUTH_HTML)
|
||||
def test_html_passed_through_for_non_redirecting_backend(self, _auth_html, _uses_redirect):
|
||||
"""
|
||||
A client which has not asked for JSON receives that same document unmodified.
|
||||
"""
|
||||
response = self.client.post(self.url, headers={'accept': 'text/html'})
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.content.decode(), self.AUTH_HTML)
|
||||
|
||||
@override_settings(AUTHENTICATION_BACKENDS=SSO_BACKENDS)
|
||||
@patch('account.views.social_auth_begin', side_effect=lambda *args, **kwargs: HttpResponse(status=502))
|
||||
def test_json_request_passes_through_error_response(self, _begin):
|
||||
"""
|
||||
Only a redirect or a rendered document is translated to JSON. An unsuccessful response is
|
||||
passed through as-is, so that the client reports the failure rather than mistaking the
|
||||
response for a login it can act on.
|
||||
"""
|
||||
response = self.client.post(self.url, headers={'accept': 'application/json'})
|
||||
|
||||
self.assertEqual(response.status_code, 502)
|
||||
self.assertNotEqual(response.headers.get('Content-Type'), 'application/json')
|
||||
|
||||
|
||||
class SocialAuthExceptionMiddlewareTestCase(SimpleTestCase):
|
||||
"""
|
||||
Verify that SSO/SAML authentication failures are surfaced as a login-page message rather than
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ from django.urls import path
|
|||
from django.views.decorators.cache import cache_page
|
||||
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView
|
||||
|
||||
from account.views import LoginView, LogoutView
|
||||
from account.views import LoginView, LogoutView, SocialAuthBeginView
|
||||
from netbox.api.views import APIRootView, AuthenticationCheckView, StatusView
|
||||
from netbox.graphql.schema import schema
|
||||
from netbox.graphql.views import NetBoxGraphQLView
|
||||
|
|
@ -20,6 +20,7 @@ _patterns = [
|
|||
# Login/logout
|
||||
path('login/', LoginView.as_view(), name='login'),
|
||||
path('logout/', LogoutView.as_view(), name='logout'),
|
||||
path('oauth/begin/<str:backend>/', SocialAuthBeginView.as_view(), name='social_auth_begin'),
|
||||
path('oauth/', include('social_django.urls', namespace='social')),
|
||||
|
||||
# Apps
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -14,6 +14,7 @@ import { initRackElevation } from './racks';
|
|||
import { initHtmx } from './htmx';
|
||||
import { initSavedFilterSelect } from './forms/savedFiltersSelect';
|
||||
import { initHotkeys } from './hotkeys';
|
||||
import { initSSOForms } from './sso';
|
||||
|
||||
function initDocument(): void {
|
||||
for (const init of [
|
||||
|
|
@ -33,6 +34,7 @@ function initDocument(): void {
|
|||
initHtmx,
|
||||
initSavedFilterSelect,
|
||||
initHotkeys,
|
||||
initSSOForms,
|
||||
]) {
|
||||
init();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,124 @@
|
|||
import { getElement, getElements } from './util';
|
||||
|
||||
// Abandon a login which has gone unanswered for this many milliseconds, so that a request which
|
||||
// hangs (a backend retrieving identity provider metadata of its own, for instance) surfaces an
|
||||
// error rather than leaving the SSO buttons disabled indefinitely.
|
||||
const REQUEST_TIMEOUT = 15000;
|
||||
|
||||
// Whether a login is already being initiated. Each login is issued a single-use state parameter,
|
||||
// which the completion view compares against the value recorded in the session, so a second login
|
||||
// would invalidate the state of the one being navigated to. This is scoped to the document rather
|
||||
// than to the form: SAML renders one form per identity provider, and all of them write the session
|
||||
// keys of the same backend.
|
||||
let loginPending = false;
|
||||
|
||||
/**
|
||||
* Enable or disable every SSO button on the page. All of them are disabled while a login is being
|
||||
* initiated, as only one login can be in flight at a time.
|
||||
*/
|
||||
function setButtonsDisabled(disabled: boolean): void {
|
||||
for (const form of getElements<HTMLFormElement>('form.sso-login-form')) {
|
||||
for (const button of form.querySelectorAll<HTMLButtonElement>('button[type="submit"]')) {
|
||||
button.disabled = disabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setErrorVisible(visible: boolean): void {
|
||||
const error = getElement('sso-error');
|
||||
if (error === null) return;
|
||||
|
||||
error.classList.toggle('d-none', !visible);
|
||||
if (visible) {
|
||||
// Revealing an alert whose text has not itself changed is not reliably announced by a screen
|
||||
// reader, so move focus to it (the login the user asked for did not begin, and the reason for
|
||||
// that is the only thing worth their attention).
|
||||
error.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Begin an SSO login by navigating to the identity provider, rather than by submitting the form.
|
||||
*
|
||||
* The social auth "begin" endpoint responds with a redirect to the identity provider. Chromium-based
|
||||
* browsers evaluate the CSP `form-action` directive against every hop in a form submission's
|
||||
* redirect chain, so a deployment which serves NetBox with `form-action 'self'` blocks that redirect
|
||||
* and the SSO button appears to do nothing. Requesting the identity provider's URL and navigating to
|
||||
* it here sidesteps the directive, which does not govern a navigation initiated by a script.
|
||||
*/
|
||||
async function beginLogin(form: HTMLFormElement): Promise<void> {
|
||||
const body = new URLSearchParams();
|
||||
for (const [name, value] of new FormData(form).entries()) {
|
||||
if (typeof value === 'string') {
|
||||
body.append(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
// Browsers predating AbortSignal.timeout() issue the request without a deadline, which is no
|
||||
// worse than the behavior they had before it was imposed.
|
||||
const signal =
|
||||
typeof AbortSignal.timeout === 'function' ? AbortSignal.timeout(REQUEST_TIMEOUT) : null;
|
||||
|
||||
const res = await fetch(form.action, {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json' },
|
||||
body,
|
||||
credentials: 'same-origin',
|
||||
signal,
|
||||
});
|
||||
if (!res.ok || !(res.headers.get('Content-Type') ?? '').includes('application/json')) {
|
||||
throw new Error(`The login request returned an unexpected response (HTTP ${res.status})`);
|
||||
}
|
||||
|
||||
const { url, html } = (await res.json()) as { url?: string; html?: string };
|
||||
if (typeof url === 'string') {
|
||||
window.location.assign(url);
|
||||
} else if (typeof html === 'string') {
|
||||
// The backend renders its own HTML (an auto-submitting form, for instance) instead of
|
||||
// redirecting. That document has already been generated by the request above, so render it in
|
||||
// place; submitting the form to fetch it again would initiate the login a second time. Note
|
||||
// that this does not evade `form-action`: the document is written into NetBox's own, so the
|
||||
// form it carries is submitted under NetBox's policy exactly as it would have been otherwise.
|
||||
document.open();
|
||||
document.write(html);
|
||||
document.close();
|
||||
} else {
|
||||
throw new Error('The login response contained neither a URL nor a document');
|
||||
}
|
||||
}
|
||||
|
||||
export function initSSOForms(): void {
|
||||
for (const form of getElements<HTMLFormElement>('form.sso-login-form')) {
|
||||
form.addEventListener('submit', event => {
|
||||
event.preventDefault();
|
||||
|
||||
if (loginPending) return;
|
||||
loginPending = true;
|
||||
setErrorVisible(false);
|
||||
setButtonsDisabled(true);
|
||||
|
||||
beginLogin(form).catch(error => {
|
||||
// Report the failure rather than falling back to submitting the form: a deployment which
|
||||
// serves NetBox with `form-action 'self'` — the very condition this indirection exists to
|
||||
// work around — blocks that submission silently, leaving a dead button and no explanation.
|
||||
// The alert shown to the user cannot say why the login failed, so log the reason.
|
||||
console.error(error);
|
||||
loginPending = false;
|
||||
setButtonsDisabled(false);
|
||||
setErrorVisible(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// A browser which restores the login page from its back/forward cache (having navigated to the
|
||||
// identity provider and returned) preserves both the DOM and the state above, which would
|
||||
// otherwise leave every SSO button permanently disabled. Only a restore may clear the guard:
|
||||
// pageshow also fires on an ordinary load, after `load` and so after a login begun in the
|
||||
// meantime, where resetting it would readmit the very double-submission it exists to prevent.
|
||||
window.addEventListener('pageshow', event => {
|
||||
if (!event.persisted) return;
|
||||
|
||||
loginPending = false;
|
||||
setButtonsDisabled(false);
|
||||
});
|
||||
}
|
||||
|
|
@ -83,11 +83,15 @@
|
|||
{% if login_form_hidden %}
|
||||
<h2 class="text-center mb-4">{% trans "Log In" %}</h2>
|
||||
{% endif %}
|
||||
{# Revealed and focused by initSSOForms() when a login cannot be initiated #}
|
||||
<div id="sso-error" class="alert alert-danger d-none" role="alert" tabindex="-1">
|
||||
{% trans "Unable to begin single sign-on. Please try again." %}
|
||||
</div>
|
||||
<div class="row">
|
||||
{% for backend in auth_backends %}
|
||||
<div class="col">
|
||||
{# The social auth begin view accepts only POST requests #}
|
||||
<form action="{{ backend.url }}" method="post">
|
||||
{# SSO logins are initiated by POST; see SocialAuthBeginView #}
|
||||
<form action="{{ backend.url }}" method="post" class="sso-login-form">
|
||||
{% csrf_token %}
|
||||
{% for param, value in backend.params.items %}
|
||||
<input type="hidden" name="{{ param }}" value="{{ value }}" />
|
||||
|
|
|
|||
Loading…
Reference in New Issue