Add OpenIDP Provider
authorPatrick Uiterwijk <puiterwijk@redhat.com>
Mon, 6 Oct 2014 17:12:13 +0000 (19:12 +0200)
committerPatrick Uiterwijk <puiterwijk@redhat.com>
Fri, 24 Oct 2014 16:02:21 +0000 (18:02 +0200)
This commit implements all the core functionality needed to expose
an OpenID Identity Provider including a framework to dynamycally add
extensions.

Signed-off-by: Patrick Uiterwijk <puiterwijk@redhat.com>
Signed-off-by: Simo Sorce <simo@redhat.com>
Reviewed-by: Patrick Uiterwijk <puiterwijk@redhat.com>
ipsilon/providers/openid/__init__.py [new file with mode: 0644]
ipsilon/providers/openid/auth.py [new file with mode: 0755]
ipsilon/providers/openid/extensions/__init__.py [new file with mode: 0644]
ipsilon/providers/openid/extensions/common.py [new file with mode: 0755]
ipsilon/providers/openid/meta.py [new file with mode: 0755]
ipsilon/providers/openidp.py [new file with mode: 0755]
ipsilon/root.py
templates/master.html
templates/openid/consent_form.html [new file with mode: 0644]
templates/openid/userpage.html [new file with mode: 0644]
templates/openid/xrds.xml [new file with mode: 0644]

diff --git a/ipsilon/providers/openid/__init__.py b/ipsilon/providers/openid/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/ipsilon/providers/openid/auth.py b/ipsilon/providers/openid/auth.py
new file mode 100755 (executable)
index 0000000..abf19ae
--- /dev/null
@@ -0,0 +1,261 @@
+#!/usr/bin/python
+#
+# Copyright (C) 2014  Ipsilon project Contributors, for licensee see COPYING
+
+from ipsilon.providers.common import ProviderPageBase
+from ipsilon.providers.common import AuthenticationError, InvalidRequest
+from ipsilon.providers.openid.meta import XRDSHandler, UserXRDSHandler
+from ipsilon.providers.openid.meta import IDHandler
+from ipsilon.util.trans import Transaction
+from ipsilon.util.user import UserSession
+
+from openid.server.server import ProtocolError, EncodingError
+
+import cherrypy
+import time
+import json
+
+
+class AuthenticateRequest(ProviderPageBase):
+
+    def __init__(self, *args, **kwargs):
+        super(AuthenticateRequest, self).__init__(*args, **kwargs)
+        self.stage = 'init'
+        self.trans = None
+
+    def _preop(self, *args, **kwargs):
+        try:
+            # generate a new id or get current one
+            self.trans = Transaction('openid', **kwargs)
+            if self.trans.cookie.value != self.trans.provider:
+                self.debug('Invalid transaction, %s != %s' % (
+                           self.trans.cookie.value, self.trans.provider))
+        except Exception, e:  # pylint: disable=broad-except
+            self.debug('Transaction initialization failed: %s' % repr(e))
+            raise cherrypy.HTTPError(400, 'Invalid transaction id')
+
+    def pre_GET(self, *args, **kwargs):
+        self._preop(*args, **kwargs)
+
+    def pre_POST(self, *args, **kwargs):
+        self._preop(*args, **kwargs)
+
+    def _get_form(self, *args):
+        form = None
+        if args is not None:
+            first = args[0] if len(args) > 0 else None
+            second = first[0] if len(first) > 0 else None
+            if type(second) is dict:
+                form = second.get('form', None)
+        return form
+
+    def auth(self, *args, **kwargs):
+        request = None
+        form = self._get_form(args)
+        try:
+            request = self._parse_request(**kwargs)
+            return self._openid_checks(request, form, **kwargs)
+        except InvalidRequest, e:
+            raise cherrypy.HTTPError(e.code, e.msg)
+        except AuthenticationError, e:
+            if request is None:
+                raise cherrypy.HTTPError(e.code, e.msg)
+            return self._respond(request.answer(False))
+
+    def _parse_request(self, **kwargs):
+        request = None
+        try:
+            request = self.cfg.server.decodeRequest(kwargs)
+        except ProtocolError, openid_error:
+            self.debug('ProtocolError: %s' % openid_error)
+            raise InvalidRequest('Invalid OpenID request', 400)
+
+        if request is None:
+            self.debug('No request')
+            raise cherrypy.HTTPRedirect(self.basepath)
+
+        return request
+
+    def _openid_checks(self, request, form, **kwargs):
+        us = UserSession()
+        user = us.get_user()
+        immediate = False
+
+        self.debug('Mode: %s Stage: %s User: %s' % (
+            kwargs['openid.mode'], self.stage, user.name))
+        if kwargs.get('openid.mode', None) == 'checkid_setup':
+            if user.is_anonymous:
+                if self.stage == 'init':
+                    returl = '%s/openid/Continue?%s' % (
+                        self.basepath, self.trans.get_GET_arg())
+                    data = {'openid_stage': 'auth',
+                            'openid_request': json.dumps(kwargs),
+                            'login_return': returl}
+                    self.trans.store(data)
+                    redirect = '%s/login?%s' % (self.basepath,
+                                                self.trans.get_GET_arg())
+                    self.debug('Redirecting: %s' % redirect)
+                    raise cherrypy.HTTPRedirect(redirect)
+                else:
+                    raise AuthenticationError("unknown user", 401)
+
+        elif kwargs.get('openid.mode', None) == 'checkid_immediate':
+            # This is immediate, so we need to assert or fail
+            if user.is_anonymous:
+                return self._respond(request.answer(False))
+
+            immediate = True
+
+        else:
+            return self._respond(self.cfg.server.handleRequest(request))
+
+        # check if this is discovery or ned identity matching checks
+        if not request.idSelect():
+            idurl = self.cfg.identity_url_template % {'username': user.name}
+            if request.identity != idurl:
+                raise AuthenticationError("User ID mismatch!", 401)
+
+        # check if the ralying party is trusted
+        if request.trust_root in self.cfg.untrusted_roots:
+            raise AuthenticationError("Untrusted Relying party", 401)
+
+        # if the party is explicitly whitelisted just respond
+        if request.trust_root in self.cfg.trusted_roots:
+            return self._respond(self._response(request, us))
+
+        allowroot = 'allow-%s' % request.trust_root
+
+        try:
+            userdata = user.load_plugin_data(self.cfg.name)
+            expiry = int(userdata[allowroot])
+        except Exception, e:  # pylint: disable=broad-except
+            self.debug(e)
+            expiry = 0
+        if expiry > int(time.time()):
+            self.debug("User has unexpired previous authorization")
+            return self._respond(self._response(request, us))
+
+        if immediate:
+            raise AuthenticationError("No consent for immediate", 401)
+
+        if self.stage == 'consent':
+            if form is None:
+                raise AuthenticationError("Unintelligible consent", 401)
+            allow = form.get('decided_allow', False)
+            if not allow:
+                raise AuthenticationError("User declined", 401)
+            try:
+                days = int(form.get('remember_for_days', '0'))
+                if days < 0 or days > 7:
+                    raise
+                userdata = {allowroot: str(int(time.time()) + (days*86400))}
+                user.save_plugin_data(self.cfg.name, userdata)
+            except Exception, e:  # pylint: disable=broad-except
+                self.debug(e)
+                days = 0
+
+            # all done we consent!
+            return self._respond(self._response(request, us))
+
+        else:
+            data = {'openid_stage': 'consent',
+                    'openid_request': json.dumps(kwargs)}
+            self.trans.store(data)
+
+            # Add extension data to this list of dictionaries
+            ad = [
+                {
+                    "Trust Root": request.trust_root,
+                },
+            ]
+            userattrs = us.get_user_attrs()
+            for n, e in self.cfg.extensions.items():
+                data = e.get_display_data(request, userattrs)
+                self.debug('%s returned %s' % (n, repr(data)))
+                ad.append(data)
+
+            context = {
+                "title": 'Consent',
+                "action": '%s/openid/Consent' % (self.basepath),
+                "trustroot": request.trust_root,
+                "username": user.name,
+                "authz_details": ad,
+            }
+            context.update(dict((self.trans.get_POST_tuple(),)))
+            # pylint: disable=star-args
+            return self._template('openid/consent_form.html', **context)
+
+    def _response(self, request, session):
+        user = session.get_user()
+        identity_url = self.cfg.identity_url_template % {'username': user.name}
+        response = request.answer(
+            True,
+            identity=identity_url,
+            claimed_id=identity_url
+        )
+        userattrs = session.get_user_attrs()
+        for _, e in self.cfg.extensions.items():
+            resp = e.get_response(request, userattrs)
+            if resp is not None:
+                response.addExtension(resp)
+        return response
+
+    def _respond(self, response):
+        try:
+            self.debug('Response: %s' % response)
+            webresponse = self.cfg.server.encodeResponse(response)
+            cherrypy.response.headers.update(webresponse.headers)
+            cherrypy.response.status = webresponse.code
+            return webresponse.body
+        except EncodingError, encoding_error:
+            self.debug('Unable to respond because: %s' % encoding_error)
+            cherrypy.response.headers = {
+                'Content-Type': 'text/plain; charset=UTF-8'
+            }
+            cherrypy.response.status = 400
+            return encoding_error.response.encodeToKVForm()
+
+
+class Continue(AuthenticateRequest):
+
+    def GET(self, *args, **kwargs):
+        transdata = self.trans.retrieve()
+        self.stage = transdata.get('openid_stage', None)
+        openid_request = transdata.get('openid_request', None)
+        if self.stage is None or openid_request is None:
+            raise AuthenticationError("unknown state", 400)
+
+        kwargs = json.loads(openid_request)
+        return self.auth(**kwargs)
+
+
+class Consent(AuthenticateRequest):
+
+    def POST(self, *args, **kwargs):
+        transdata = self.trans.retrieve()
+        self.stage = transdata.get('openid_stage', None)
+        openid_request = transdata.get('openid_request', None)
+        if self.stage is None or openid_request is None:
+            raise AuthenticationError("unknown state", 400)
+
+        args = ({'form': kwargs},)
+        kwargs = json.loads(openid_request)
+        return self.auth(*args, **kwargs)
+
+
+class OpenID(AuthenticateRequest):
+
+    def __init__(self, *args, **kwargs):
+        super(OpenID, self).__init__(*args, **kwargs)
+        self.XRDS = XRDSHandler(*args, **kwargs)
+        self.yadis = UserXRDSHandler(*args, **kwargs)
+        self.id = IDHandler(*args, **kwargs)
+        self.Continue = Continue(*args, **kwargs)
+        self.Consent = Consent(*args, **kwargs)
+        self.trans = None
+
+    def GET(self, *args, **kwargs):
+        return self.auth(**kwargs)
+
+    def POST(self, *args, **kwargs):
+        return self.auth(**kwargs)
diff --git a/ipsilon/providers/openid/extensions/__init__.py b/ipsilon/providers/openid/extensions/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/ipsilon/providers/openid/extensions/common.py b/ipsilon/providers/openid/extensions/common.py
new file mode 100755 (executable)
index 0000000..b75d394
--- /dev/null
@@ -0,0 +1,67 @@
+#!/usr/bin/python
+#
+# Copyright (C) 2014  Ipsilon project Contributors, for licensee see COPYING
+
+from __future__ import absolute_import
+
+from ipsilon.providers.common import FACILITY
+from ipsilon.util.plugin import PluginLoader
+from ipsilon.util.log import Log
+
+
+class OpenidExtensionBase(Log):
+
+    def __init__(self, name=None):
+        self.name = name
+        self.enabled = False
+        self.type_uris = []
+
+    def _display(self, request, userdata):
+        raise NotImplementedError
+
+    def _response(self, request, userdata):
+        raise NotImplementedError
+
+    def get_type_uris(self):
+        if self.enabled:
+            return self.type_uris
+        return []
+
+    def get_display_data(self, request, userdata):
+        if self.enabled:
+            return self._display(request, userdata)
+        return {}
+
+    def get_response(self, request, userdata):
+        if self.enabled:
+            return self._response(request, userdata)
+        return None
+
+    def enable(self):
+        self.enabled = True
+
+    def disable(self):
+        self.enabled = False
+
+
+FACILITY = 'openid_extensions'
+
+
+class LoadExtensions(Log):
+
+    def __init__(self, enabled):
+        loader = PluginLoader(LoadExtensions, FACILITY, 'OpenidExtension')
+        self.plugins = loader.get_plugin_data()
+
+        available = self.plugins['available'].keys()
+        self._debug('Available Extensions: %s' % str(available))
+
+        for item in enabled:
+            if item not in self.plugins['available']:
+                self.debug('<%s> not available' % item)
+                continue
+            self.debug('Enable OpenId extension: %s' % item)
+            self.plugins['available'][item].enable()
+
+    def get_extensions(self):
+        return self.plugins['available']
diff --git a/ipsilon/providers/openid/meta.py b/ipsilon/providers/openid/meta.py
new file mode 100755 (executable)
index 0000000..a04a78c
--- /dev/null
@@ -0,0 +1,102 @@
+#!/usr/bin/python
+#
+# Copyright (C) 2014  Ipsilon project Contributors, for licensee see COPYING
+
+from ipsilon.providers.common import ProviderPageBase
+
+import cherrypy
+
+
+class MetaHandler(ProviderPageBase):
+
+    def __init__(self, *args, **kwargs):
+        super(MetaHandler, self).__init__(*args, **kwargs)
+        self.default_headers.update({
+            'Cache-Control': 'no-cache, must-revalidate',
+            'Pragma': 'no-cache',
+            'Expires': 'Thu, 01 Dec 1994 16:00:00 GMT',
+        })
+        self._template_name = None
+        self._take_args = False
+
+    def reply(self, **kwargs):
+        if self._template_name is None:
+            raise ValueError('Template not set')
+        return self._template(self._template_name, **kwargs)
+
+    def default(self, *args, **kwargs):
+        if self._take_args:
+            return self.root(*args, **kwargs)
+        raise cherrypy.NotFound()
+
+
+class XRDSHandler(MetaHandler):
+
+    def __init__(self, *args, **kwargs):
+        super(XRDSHandler, self).__init__(*args, **kwargs)
+        self.default_headers['Content-Type'] = 'application/xrds+xml'
+        self._template_name = 'openid/xrds.xml'
+
+    def GET(self, *args, **kwargs):
+        types = [
+            'http://specs.openid.net/auth/2.0/server',
+            'http://openid.net/server/1.0',
+        ]
+        for _, e in self.cfg.extensions.items():
+            types.extend(e.get_type_uris())
+
+        return self.reply(types=types,
+                          uri=self.cfg.endpoint_url)
+
+
+class UserXRDSHandler(XRDSHandler):
+
+    def __init__(self, *args, **kwargs):
+        super(UserXRDSHandler, self).__init__(*args, **kwargs)
+        self._take_args = True
+
+    def GET(self, *args, **kwargs):
+        if len(args) != 1:
+            raise cherrypy.NotFound()
+        if args[0].endswith('.xrds'):
+            name = args[0][:-5]
+            identity_url = self.cfg.identity_url_template % {'username': name}
+            types = [
+                'http://specs.openid.net/auth/2.0/signon',
+                'http://openid.net/signon/1.0',
+            ]
+            for _, e in self.cfg.extensions.items():
+                types.extend(e.get_type_uris())
+
+            return self.reply(types=types,
+                              uri=self.cfg.endpoint_url,
+                              localid=identity_url)
+
+        raise cherrypy.NotFound()
+
+
+class IDHandler(MetaHandler):
+
+    def __init__(self, *args, **kwargs):
+        super(IDHandler, self).__init__(*args, **kwargs)
+        self._template_name = 'openid/userpage.html'
+        self._take_args = True
+
+    def GET(self, *args, **kwargs):
+        if len(args) != 1:
+            raise cherrypy.NotFound()
+        name = args[0]
+        yadis = '%syadis/%s.xrds' % (self.cfg.endpoint_url, name)
+        cherrypy.response.headers['X-XRDS-Location'] = yadis
+
+        endpoint_url = self.cfg.endpoint_url
+        identity_url = self.cfg.identity_url_template % {'username': name}
+
+        HEAD_LINK = '<link rel="%s" href="%s">'
+        provider_heads = [HEAD_LINK % ('openid2.provider', endpoint_url),
+                          HEAD_LINK % ('openid.server', endpoint_url)]
+        user_heads = [HEAD_LINK % ('openid2.delegate', identity_url),
+                      HEAD_LINK % ('openid.local_id', identity_url)]
+        heads = {'provider': provider_heads, 'user': user_heads}
+
+        return self.reply(title='Userpage', username=name, heads=heads)
diff --git a/ipsilon/providers/openidp.py b/ipsilon/providers/openidp.py
new file mode 100755 (executable)
index 0000000..2e41050
--- /dev/null
@@ -0,0 +1,150 @@
+#!/usr/bin/python
+#
+# Copyright (C) 2014  Ipsilon project Contributors, for licensee see COPYING
+
+from __future__ import absolute_import
+
+from ipsilon.providers.common import ProviderBase
+from ipsilon.providers.common import FACILITY
+from ipsilon.providers.openid.auth import OpenID
+from ipsilon.providers.openid.extensions.common import LoadExtensions
+from ipsilon.util.plugin import PluginObject
+
+from openid.server.server import Server
+# TODO: Move this to the database
+from openid.store.memstore import MemoryStore
+
+
+class IdpProvider(ProviderBase):
+
+    def __init__(self):
+        super(IdpProvider, self).__init__('openid', 'openid')
+        self.page = None
+        self.server = None
+        self.basepath = None
+        self.extensions = None
+        self.description = """
+Provides OpenID 2.0 authentication infrastructure. """
+
+        self._options = {
+            'default email domain': [
+                """Default email domain, for users missing email property.""",
+                'string',
+                'example.com'
+            ],
+            'endpoint url': [
+                """The Absolute URL of the OpenID provider""",
+                'string',
+                'http://localhost:8080/idp/openid/'
+            ],
+            'identity url template': [
+                """The templated URL where identities are exposed.""",
+                'string',
+                'http://localhost:8080/idp/openid/id/%(username)s'
+            ],
+            'trusted roots': [
+                """List of trusted relying parties.""",
+                'list',
+                []
+            ],
+            'untrusted roots': [
+                """List of untrusted relying parties.""",
+                'list',
+                []
+            ],
+            'enabled extensions': [
+                """List of enabled extensions""",
+                'list',
+                []
+            ],
+        }
+
+    @property
+    def endpoint_url(self):
+        url = self.get_config_value('endpoint url')
+        if url.endswith('/'):
+            return url
+        else:
+            return url+'/'
+
+    @property
+    def default_email_domain(self):
+        return self.get_config_value('default email domain')
+
+    @property
+    def identity_url_template(self):
+        url = self.get_config_value('identity url template')
+        if url.endswith('/'):
+            return url
+        else:
+            return url+'/'
+
+    @property
+    def trusted_roots(self):
+        return self.get_config_value('trusted roots')
+
+    @property
+    def untrusted_roots(self):
+        return self.get_config_value('untrusted roots')
+
+    @property
+    def enabled_extensions(self):
+        return self.get_config_value('enabled extensions')
+
+    def get_tree(self, site):
+        self.init_idp()
+        self.page = OpenID(site, self)
+        # self.admin = AdminPage(site, self)
+
+        # Expose OpenID presence in the root
+        headers = site[FACILITY]['root'].default_headers
+        headers['X-XRDS-Location'] = self.endpoint_url+'XRDS'
+
+        html_heads = site[FACILITY]['root'].html_heads
+        HEAD_LINK = '<link rel="%s" href="%s">'
+        openid_heads = [HEAD_LINK % ('openid2.provider', self.endpoint_url),
+                        HEAD_LINK % ('openid.server', self.endpoint_url)]
+        html_heads['openid'] = openid_heads
+
+        return self.page
+
+    def init_idp(self):
+        self.server = Server(MemoryStore(), op_endpoint=self.endpoint_url)
+        loader = LoadExtensions(self.enabled_extensions)
+        self.extensions = loader.get_extensions()
+
+    def on_enable(self):
+        self.init_idp()
+
+
+class Installer(object):
+
+    def __init__(self):
+        self.name = 'openid'
+        self.ptype = 'provider'
+
+    def install_args(self, group):
+        group.add_argument('--openid', choices=['yes', 'no'], default='yes',
+                           help='Configure OpenID Provider')
+
+    def configure(self, opts):
+        if opts['openid'] != 'yes':
+            return
+
+        proto = 'https'
+        if opts['secure'].lower() == 'no':
+            proto = 'http'
+        url = '%s://%s/%s/openid/' % (
+            proto, opts['hostname'], opts['instance'])
+
+        # Add configuration data to database
+        po = PluginObject()
+        po.name = 'openid'
+        po.wipe_data()
+
+        po.wipe_config_values(FACILITY)
+        config = {'endpoint url': url,
+                  'identity_url_template': '%sid/%%(username)s' % url,
+                  'enabled': '1'}
+        po.set_config(config)
+        po.save_plugin_config(FACILITY)
index b2654ac..e214115 100755 (executable)
@@ -39,6 +39,7 @@ class Root(Page):
         if template_env:
             sites[site]['template_env'] = template_env
         super(Root, self).__init__(sites[site])
+        self.html_heads = dict()
 
         # set up error pages
         cherrypy.config['error_page.400'] = errors.Error_400(self._site)
@@ -60,4 +61,6 @@ class Root(Page):
         ProviderPlugins(self._site, self.admin)
 
     def root(self):
-        return self._template('index.html', title='Ipsilon')
+        self.debug(self.html_heads)
+        return self._template('index.html', title='Ipsilon',
+                              heads=self.html_heads)
index 21178fc..cf1275a 100644 (file)
@@ -8,6 +8,13 @@
     <meta charset="UTF-8">
     <meta name="viewport" content="width=device-width, initial-scale=1.0">
     <link href="{{ basepath }}/ui/css/ipsilon.css" rel="stylesheet" media="screen, print">
+    {%- if heads %}
+      {%- for group, value in heads.items() %}
+        {%- for head in value %}
+    {{ head }}
+        {%- endfor %}
+      {%- endfor %}
+    {%- endif %}
   </head>
   <body>
     <a href="{{ basepath }}/" id="badge" tabindex="-1">
diff --git a/templates/openid/consent_form.html b/templates/openid/consent_form.html
new file mode 100644 (file)
index 0000000..8c3813e
--- /dev/null
@@ -0,0 +1,44 @@
+{% extends "master.html" %}
+{% block main %}
+
+<div class="col-sm-12">
+  <p>The OpenID realying party <b>{{ trustroot }}</b> is asking
+     to authorize access for <b>{{ username }}</b>.</p>
+  <p>Please review the authorization details</p>
+</div>
+
+<div class="col-sm-7 col-md-6 col-lg-5 login">
+  <form class="form-horizontal" role="form" id="consent_form" action="{{ action }}" method="post" enctype="application/x-www-form-urlencoded">
+    <input type="hidden" name="ipsilon_transaction_id" id="ipsilon_transaction_id" value="{{ ipsilon_transaction_id }}">
+    <div class="alert alert-danger">
+{%- for items in authz_details %}
+  {%- for item in items|dictsort %}
+        <div class="form-group">
+            <div class="col-sm-10 col-md-10">{{ item[0] }}:</div>
+            <div class="col-sm-10 col-md-10">{{ item[1] }}</div>
+        </div>
+    {%- endfor %}
+{%- endfor %}
+    </div>
+    <div class="form-group">
+      <label for="remember_for_days" class="col-sm-10 col-md-10">
+        Remember authorization for
+      </label>
+      <div class="col-sm-10 col-md-10">
+        <select name="remember_for_days">
+          <option value="0">never</option>
+          <option value="3">3 days</option>
+          <option value="7">7 days</option>
+        </select>
+      </div>
+    </div>
+    <div class="form-group">
+      <div class="col-sm-offset-2 col-md-offset-2 col-xs-12 col-sm-10 col-md-10 submit">
+        <button type="submit" name="decided_deny" value="Reject" class="btn btn-primary btn-lg" tabindex="3">Reject</button>
+        <button type="submit" name="decided_allow" value="Allow" class="btn btn-primary btn-lg" tabindex="3">Allow</button>
+      </div>
+    </div>
+  </form>
+</div>
+
+{% endblock %}
diff --git a/templates/openid/userpage.html b/templates/openid/userpage.html
new file mode 100644 (file)
index 0000000..db87974
--- /dev/null
@@ -0,0 +1,10 @@
+{% extends "master.html" %}
+{% block main %}
+<div class="col-sm-12">
+  <div id="content">
+    <p>This is the OpenID landing page for {{ username }}</p>
+    <p>This page is used by the OpenID protocol and has no other use.</p>
+    <p>You can go to the <a href="{{ basepath }}">main page</a> instead.</p>
+  </div>
+</div>
+{% endblock %}
diff --git a/templates/openid/xrds.xml b/templates/openid/xrds.xml
new file mode 100644 (file)
index 0000000..86b3e0f
--- /dev/null
@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<xrds:XRDS xmlns:xrds="xri://$xrds" xmlns="xri://$xrd*($v*2.0)">
+  <XRD>
+    <Service>
+{%- for t in types %}
+      <Type>{{ t }}</Type>
+{%- endfor %}
+{%- if uri %}
+      <URI>{{ uri }}</URI>
+{%- endif %}
+{%- if localid %}
+      <LocalID>{{ localid }}</LocalID>
+{%- endif %}
+    </Service>
+  </XRD>
+</xrds:XRDS>