diff --git a/etc/sso_callback_template.html b/etc/sso_callback_template.html
new file mode 100644
index 0000000000..c6997dc466
--- /dev/null
+++ b/etc/sso_callback_template.html
@@ -0,0 +1,22 @@
+
+
+
+ Keystone WebSSO redirect
+
+
+
+
+
+
\ No newline at end of file
diff --git a/keystone/common/config.py b/keystone/common/config.py
index b516f23b38..06f9413783 100644
--- a/keystone/common/config.py
+++ b/keystone/common/config.py
@@ -19,6 +19,7 @@ import oslo_messaging
_DEFAULT_AUTH_METHODS = ['external', 'password', 'token']
_CERTFILE = '/etc/keystone/ssl/certs/signing_cert.pem'
_KEYFILE = '/etc/keystone/ssl/private/signing_key.pem'
+_SSO_CALLBACK = '/etc/keystone/sso_callback_template.html'
FILE_OPTIONS = {
@@ -520,6 +521,17 @@ FILE_OPTIONS = {
'unless you really have to. Changing this option '
'to empty string or None will not have any impact and '
'default name will be used.'),
+ cfg.MultiStrOpt('trusted_dashboard', default=[],
+ help='A list of trusted dashboard hosts. Before '
+ 'accepting a Single Sign-On request to return a '
+ 'token, the origin host must be a member of the '
+ 'trusted_dashboard list. This configuration '
+ 'option may be repeated for multiple values. '
+ 'For example: trusted_dashboard=http://acme.com '
+ 'trusted_dashboard=http://beta.com'),
+ cfg.StrOpt('sso_callback_template', default=_SSO_CALLBACK,
+ help='Location of Single Sign-On callback handler, will '
+ 'return a token to a trusted dashboard host.'),
],
'policy': [
cfg.StrOpt('driver',
diff --git a/keystone/contrib/federation/controllers.py b/keystone/contrib/federation/controllers.py
index dd60e76b40..f4fdefb0d0 100644
--- a/keystone/contrib/federation/controllers.py
+++ b/keystone/contrib/federation/controllers.py
@@ -13,6 +13,11 @@
"""Extensions supporting Federation."""
from oslo_config import cfg
+import string
+
+from oslo_log import log
+from six.moves import urllib
+import webob
from keystone.auth import controllers as auth_controllers
from keystone.common import authorization
@@ -29,6 +34,7 @@ from keystone.models import token_model
CONF = cfg.CONF
+LOG = log.getLogger(__name__)
class _ControllerBase(controller.V3Controller):
@@ -259,6 +265,47 @@ class Auth(auth_controllers.Auth):
return self.authenticate_for_token(context, auth=auth)
+ def federated_sso_auth(self, context, protocol_id):
+ try:
+ remote_id_name = CONF.federation.remote_id_attribute
+ identity_provider = context['environment'][remote_id_name]
+ except KeyError:
+ msg = _('Missing entity ID from environment')
+ LOG.error(msg)
+ raise exception.Unauthorized(msg)
+
+ if 'origin' in context['query_string']:
+ origin = context['query_string'].get('origin')
+ host = urllib.parse.unquote_plus(origin)
+ else:
+ msg = _('Request must have an origin query parameter')
+ LOG.error(msg)
+ raise exception.ValidationError(msg)
+
+ if host in CONF.federation.trusted_dashboard:
+ res = self.federated_authentication(context, identity_provider,
+ protocol_id)
+ token_id = res.headers['X-Subject-Token']
+ return self.render_html_response(host, token_id)
+ else:
+ msg = _('%(host)s is not a trusted dashboard host')
+ msg = msg % {'host': host}
+ LOG.error(msg)
+ raise exception.Unauthorized(msg)
+
+ def render_html_response(self, host, token_id):
+ """Forms an HTML Form from a template with autosubmit."""
+
+ headers = [('Content-Type', 'text/html')]
+
+ with open(CONF.federation.sso_callback_template) as template:
+ src = string.Template(template.read())
+
+ subs = {'host': host, 'token': token_id}
+ body = src.substitute(subs)
+ return webob.Response(body=body, status='200',
+ headerlist=headers)
+
@validation.validated(schema.saml_create, 'auth')
def create_saml_assertion(self, context, auth):
"""Exchange a scoped token for a SAML assertion.
diff --git a/keystone/contrib/federation/routers.py b/keystone/contrib/federation/routers.py
index 97698c0247..49229bdfd6 100644
--- a/keystone/contrib/federation/routers.py
+++ b/keystone/contrib/federation/routers.py
@@ -77,6 +77,12 @@ class FederationExtension(wsgi.V3ExtensionRouter):
POST /auth/OS-FEDERATION/saml2
GET /OS-FEDERATION/saml2/metadata
+ GET /auth/OS-FEDERATION/websso/{protocol_id}
+ ?origin=https%3A//horizon.example.com
+
+ POST /auth/OS-FEDERATION/websso/{protocol_id}
+ ?origin=https%3A//horizon.example.com
+
"""
def _construct_url(self, suffix):
return "/OS-FEDERATION/%s" % suffix
@@ -207,6 +213,14 @@ class FederationExtension(wsgi.V3ExtensionRouter):
path='/auth' + self._construct_url('saml2'),
post_action='create_saml_assertion',
rel=build_resource_relation(resource_name='saml2'))
+ self._add_resource(
+ mapper, auth_controller,
+ path='/auth' + self._construct_url('websso/{protocol_id}'),
+ get_post_action='federated_sso_auth',
+ rel=build_resource_relation(resource_name='websso'),
+ path_vars={
+ 'protocol_id': PROTOCOL_ID_PARAMETER_RELATION,
+ })
# Keystone-Identity-Provider metadata endpoint
self._add_resource(
diff --git a/keystone/tests/unit/test_v3_federation.py b/keystone/tests/unit/test_v3_federation.py
index 484698e7fd..6c02d153b8 100644
--- a/keystone/tests/unit/test_v3_federation.py
+++ b/keystone/tests/unit/test_v3_federation.py
@@ -24,6 +24,7 @@ from oslotest import mockpatch
import saml2
from saml2 import saml
from saml2 import sigver
+from six.moves import urllib
import xmldsig
from keystone.auth import controllers as auth_controllers
@@ -32,6 +33,7 @@ from keystone.contrib.federation import idp as keystone_idp
from keystone.contrib.federation import utils as mapping_utils
from keystone import exception
from keystone import notifications
+from keystone.tests.unit import core
from keystone.tests.unit import federation_fixtures
from keystone.tests.unit import mapping_fixtures
from keystone.tests.unit import test_v3
@@ -1937,10 +1939,10 @@ class FederatedTokenTests(FederationTests):
self.tokens['ADMIN_ASSERTION'], 'domain',
self.domainC['id'])
- def _inject_assertion(self, context, variant):
+ def _inject_assertion(self, context, variant, query_string=None):
assertion = getattr(mapping_fixtures, variant)
context['environment'].update(assertion)
- context['query_string'] = []
+ context['query_string'] = query_string or []
class FederatedTokenTestsMethodToken(FederatedTokenTests):
@@ -2590,3 +2592,64 @@ class ServiceProviderTests(FederationTests):
def test_delete_service_provider_404(self):
url = self.base_url(suffix=uuid.uuid4().hex)
self.delete(url, expected_status=404)
+
+
+class WebSSOTests(FederatedTokenTests):
+ """A class for testing Web SSO."""
+
+ SSO_URL = '/auth/OS-FEDERATION/websso/'
+ SSO_TEMPLATE_NAME = 'sso_callback_template.html'
+ SSO_TEMPLATE_PATH = os.path.join(core.dirs.etc(), SSO_TEMPLATE_NAME)
+ TRUSTED_DASHBOARD = 'http://horizon.com'
+ ORIGIN = urllib.parse.quote_plus(TRUSTED_DASHBOARD)
+
+ def setUp(self):
+ super(WebSSOTests, self).setUp()
+ self.api = federation_controllers.Auth()
+
+ def config_overrides(self):
+ super(WebSSOTests, self).config_overrides()
+ self.config_fixture.config(
+ group='federation',
+ trusted_dashboard=[self.TRUSTED_DASHBOARD],
+ sso_callback_template=self.SSO_TEMPLATE_PATH,
+ remote_id_attribute=self.REMOTE_ID_ATTR)
+
+ def test_render_callback_template(self):
+ token_id = uuid.uuid4().hex
+ resp = self.api.render_html_response(self.TRUSTED_DASHBOARD, token_id)
+ self.assertIn(token_id, resp.body)
+ self.assertIn(self.TRUSTED_DASHBOARD, resp.body)
+
+ def test_federated_sso_auth(self):
+ environment = {self.REMOTE_ID_ATTR: self.IDP}
+ context = {'environment': environment}
+ query_string = {'origin': self.ORIGIN}
+ self._inject_assertion(context, 'EMPLOYEE_ASSERTION', query_string)
+ resp = self.api.federated_sso_auth(context, self.PROTOCOL)
+ self.assertIn(self.TRUSTED_DASHBOARD, resp.body)
+
+ def test_federated_sso_missing_query(self):
+ environment = {self.REMOTE_ID_ATTR: self.IDP}
+ context = {'environment': environment}
+ self._inject_assertion(context, 'EMPLOYEE_ASSERTION')
+ self.assertRaises(exception.ValidationError,
+ self.api.federated_sso_auth,
+ context, self.PROTOCOL)
+
+ def test_federated_sso_untrusted_dashboard(self):
+ environment = {self.REMOTE_ID_ATTR: self.IDP}
+ context = {'environment': environment}
+ query_string = {'origin': uuid.uuid4().hex}
+ self._inject_assertion(context, 'EMPLOYEE_ASSERTION', query_string)
+ self.assertRaises(exception.Unauthorized,
+ self.api.federated_sso_auth,
+ context, self.PROTOCOL)
+
+ def test_federated_sso_missing_remote_id(self):
+ context = {'environment': {}}
+ query_string = {'origin': self.ORIGIN}
+ self._inject_assertion(context, 'EMPLOYEE_ASSERTION', query_string)
+ self.assertRaises(exception.Unauthorized,
+ self.api.federated_sso_auth,
+ context, self.PROTOCOL)
diff --git a/keystone/tests/unit/test_versions.py b/keystone/tests/unit/test_versions.py
index 60609d928c..4c526c239f 100644
--- a/keystone/tests/unit/test_versions.py
+++ b/keystone/tests/unit/test_versions.py
@@ -312,6 +312,10 @@ V3_JSON_HOME_RESOURCES_INHERIT_DISABLED = {
json_home.build_v3_resource_relation('users'): {'href': '/users'},
_build_federation_rel(resource_name='domains'): {
'href': '/OS-FEDERATION/domains'},
+ _build_federation_rel(resource_name='websso'): {
+ 'href-template': '/auth/OS-FEDERATION/websso/{protocol_id}',
+ 'href-vars': {
+ 'protocol_id': PROTOCOL_ID_PARAM_RELATION, }},
_build_federation_rel(resource_name='projects'): {
'href': '/OS-FEDERATION/projects'},
_build_federation_rel(resource_name='saml2'): {