diff --git a/MANIFEST.in b/MANIFEST.in
index f6edbec..9965876 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,6 +1,10 @@
 include *.in
-include *.py
 include LICENSE
 include tox.ini
 include requirements.txt
+
+prune doc
+
+recursive-include matrix_synapse_saml_mozilla *.py
+graft matrix_synapse_saml_mozilla/res
 recursive-include tests *.py
diff --git a/README.md b/README.md
index 0d86591..2b89eff 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,7 @@
-# Synapse Mozilla SAML MXID Mapper 
+# Synapse Mozilla SAML MXID Mapper
 
-Custom SAML auth response -> MXID mapping algorithm used during the Mozilla
-Matrix trial run.
+A Synapse plugin module which allows users to choose their username when they
+first log in.
 
 ## Installation
 
@@ -11,8 +11,6 @@ This plugin can be installed via [PyPi](https://pypi.org):
 pip install matrix-synapse-saml-mozilla
 ```
 
-## Usage
-
 ### Config
 
 Add the following in your Synapse config:
@@ -21,8 +19,22 @@ Add the following in your Synapse config:
    saml2_config:
      user_mapping_provider:
        module: "matrix_synapse_saml_mozilla.SamlMappingProvider"
-       config:
-         mxid_source_attribute: "uid"
+```
+
+Also, under the HTTP client `listener`, configure an `additional_resource` as per
+the below:
+
+```yaml
+listeners:
+  - port: <port>
+    type: http
+
+    resources:
+      - names: [client]
+
+    additional_resources:
+      "/_matrix/saml2/pick_username":
+        module: "matrix_synapse_saml_mozilla.pick_username_resource"
 ```
 
 ### Configuration Options
@@ -30,11 +42,13 @@ Add the following in your Synapse config:
 Synapse allows SAML mapping providers to specify custom configuration through the
 `saml2_config.user_mapping_provider.config` option.
 
-The options supported by this provider are currently:
+There are no options currently supported by this provider.
+
+## Implementation notes
+
+The login flow looks something like this:
 
-* `mxid_source_attribute` - The SAML attribute (after mapping via the
-                            attribute maps) to use to derive the Matrix
-                            ID from. 'uid' by default.
+![login flow](doc/login_flow.svg)
 
 ## Development and Testing
 
@@ -42,7 +56,7 @@ This repository uses `tox` to run linting and tests.
 
 ### Linting
 
-Code is linted with the `flake8` tool. Run `tox -e pep8` to check for linting
+Code is linted with the `flake8` tool. Run `tox -e lint` to check for linting
 errors in the codebase.
 
 ### Tests
diff --git a/doc/login_flow.svg b/doc/login_flow.svg
new file mode 100644
index 0000000..cb2517f
--- /dev/null
+++ b/doc/login_flow.svg
@@ -0,0 +1 @@
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="943" height="1377"><defs/><g><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g><rect fill="white" stroke="none" x="0" y="0" width="943" height="1377"/></g><g><text fill="black" stroke="none" font-family="sans-serif" font-size="16.5pt" font-style="normal" font-weight="normal" text-decoration="normal" x="355.76911651231967" y="24.50280495" text-anchor="start" dominant-baseline="alphabetic">Mozilla matrix login flow</text></g><g/><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 38.923050770539064 95.887643371 L 38.923050770539064 1377.8743983549996" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray="12.565541,5.445067766666667"/><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 235.7823911040026 266.753869889 L 235.7823911040026 1217.1359978829996" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray="12.565541,5.445067766666667"/><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 648.6303460099922 95.887643371 L 648.6303460099922 1377.8743983549996" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray="12.565541,5.445067766666667"/><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 880.8695691559558 95.887643371 L 880.8695691559558 1377.8743983549996" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray="12.565541,5.445067766666667"/></g><g><path fill="none" stroke="none"/><g><path fill="white" stroke="black" paint-order="fill stroke markers" d=" M 8.167601650000002 51.782594461 L 69.67849989107813 51.782594461 L 69.67849989107813 95.887643371 L 8.167601650000002 95.887643371 L 8.167601650000002 51.782594461 Z" stroke-miterlimit="10" stroke-width="2.613632528" stroke-dasharray=""/></g><g><g/><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="25.8912972305" y="79.55244007099999" text-anchor="start" dominant-baseline="alphabetic">Riot</text></g><path fill="none" stroke="none"/><g><path fill="white" stroke="black" paint-order="fill stroke markers" d=" M 602.3885077292969 51.945946494000005 L 694.8721842906875 51.945946494000005 L 694.8721842906875 95.887643371 L 602.3885077292969 95.887643371 L 602.3885077292969 51.945946494000005 Z" stroke-miterlimit="10" stroke-width="2.613632528" stroke-dasharray=""/></g><g><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="620.1122033097969" y="79.55244007099999" text-anchor="start" dominant-baseline="alphabetic">Synapse</text></g><path fill="none" stroke="none"/><g><path fill="white" stroke="black" paint-order="fill stroke markers" d=" M 826.0794444372722 51.945946494000005 L 935.6596938746394 51.945946494000005 L 935.6596938746394 95.887643371 L 826.0794444372722 95.887643371 L 826.0794444372722 51.945946494000005 Z" stroke-miterlimit="10" stroke-width="2.613632528" stroke-dasharray=""/></g><g><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="843.8031400177722" y="79.55244007099999" text-anchor="start" dominant-baseline="alphabetic">SAML2 IdP</text></g></g><g><path fill="white" stroke="black" paint-order="fill stroke markers" d=" M 30.755449120539062 104.05524502099999 L 47.090652420539065 104.05524502099999 L 47.090652420539065 1361.5391950549995 L 30.755449120539062 1361.5391950549995 L 30.755449120539062 104.05524502099999" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray=""/><path fill="white" stroke="black" paint-order="fill stroke markers" d=" M 640.4627443599921 149.793814261 L 656.7979476599922 149.793814261 L 656.7979476599922 195.532383501 L 640.4627443599921 195.532383501 L 640.4627443599921 149.793814261" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray=""/><path fill="white" stroke="black" paint-order="fill stroke markers" d=" M 227.61478945400262 312.492439129 L 243.9499927540026 312.492439129 L 243.9499927540026 1217.1359978829996 L 227.61478945400262 1217.1359978829996 L 227.61478945400262 312.492439129" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray=""/><path fill="white" stroke="black" paint-order="fill stroke markers" d=" M 640.4627443599921 370.319058811 L 656.7979476599922 370.319058811 L 656.7979476599922 483.03196158099996 L 640.4627443599921 483.03196158099996 L 640.4627443599921 370.319058811" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray=""/><path fill="white" stroke="black" paint-order="fill stroke markers" d=" M 872.7019675059557 540.8585812629999 L 889.0371708059558 540.8585812629999 L 889.0371708059558 586.5971505029999 L 872.7019675059557 586.5971505029999 L 872.7019675059557 540.8585812629999" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray=""/><path fill="white" stroke="black" paint-order="fill stroke markers" d=" M 872.7019675059557 632.3357197429998 L 889.0371708059558 632.3357197429998 L 889.0371708059558 678.0742889829997 L 872.7019675059557 678.0742889829997 L 872.7019675059557 632.3357197429998" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray=""/><path fill="white" stroke="black" paint-order="fill stroke markers" d=" M 640.4627443599921 735.9009086649997 L 656.7979476599922 735.9009086649997 L 656.7979476599922 860.7018618769997 L 640.4627443599921 860.7018618769997 L 640.4627443599921 735.9009086649997" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray=""/><path fill="white" stroke="black" paint-order="fill stroke markers" d=" M 640.4627443599921 906.4404311169997 L 656.7979476599922 906.4404311169997 L 656.7979476599922 952.1790003569996 L 640.4627443599921 952.1790003569996 L 640.4627443599921 906.4404311169997" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray=""/><path fill="white" stroke="black" paint-order="fill stroke markers" d=" M 640.4627443599921 1010.0056200389996 L 656.7979476599922 1010.0056200389996 L 656.7979476599922 1067.8322397209995 L 640.4627443599921 1067.8322397209995 L 640.4627443599921 1010.0056200389996" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray=""/><path fill="white" stroke="black" paint-order="fill stroke markers" d=" M 640.4627443599921 1125.6588594029995 L 656.7979476599922 1125.6588594029995 L 656.7979476599922 1171.3974286429996 L 640.4627443599921 1171.3974286429996 L 640.4627443599921 1125.6588594029995" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray=""/><path fill="white" stroke="black" paint-order="fill stroke markers" d=" M 640.4627443599921 1274.9626175649996 L 656.7979476599922 1274.9626175649996 L 656.7979476599922 1320.7011868049997 L 640.4627443599921 1320.7011868049997 L 640.4627443599921 1274.9626175649996" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray=""/><g><g><rect fill="white" stroke="none" x="297.20738408178903" y="128.558049971" width="93.13862861695313" height="21.23576429"/></g><text fill="black" stroke="none" font-family="monospace" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="299.65766457678905" y="143.259732941" text-anchor="start" dominant-baseline="alphabetic">GET /login</text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 47.090652420539065 149.793814261 L 637.7402104766588 149.793814261" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray=""/><g transform="translate(640.4627443599921,149.793814261) translate(-640.4627443599921,-149.793814261)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 626.8500749433255 142.98747955266666 L 640.4627443599921 149.793814261 L 626.8500749433255 156.60014896933333 Z"/></g></g><g><g><rect fill="white" stroke="none" x="253.0883502683125" y="174.296619211" width="181.37669624390625" height="21.23576429"/></g><text fill="black" stroke="none" font-family="monospace" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="255.53863076331248" y="188.99830218099999" text-anchor="start" dominant-baseline="alphabetic">"type":"m.login.sso"</text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 640.4627443599921 195.532383501 L 49.8131863038724 195.532383501" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray="6.53408132"/><g transform="translate(47.090652420539065,195.532383501) translate(-47.090652420539065,-195.532383501)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 60.703321837205735 188.72604879266666 L 47.090652420539065 195.532383501 L 60.703321837205735 202.33871820933334 Z"/></g></g><path fill="none" stroke="none"/><g><path fill="white" stroke="black" paint-order="fill stroke markers" d=" M 148.82079574322916 222.81217301200002 L 322.74398646477607 222.81217301200002 L 322.74398646477607 266.753869889 L 148.82079574322916 266.753869889 L 148.82079574322916 222.81217301200002 Z" stroke-miterlimit="10" stroke-width="2.613632528" stroke-dasharray=""/></g><g><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="166.54449132372918" y="250.418666589" text-anchor="start" dominant-baseline="alphabetic">(Embedded) Browser</text></g><g><g><rect fill="white" stroke="none" x="134.90244044227086" y="291.25667483899997" width="4.90056099" height="21.23576429"/></g><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="137.35272093727085" y="305.95835780899995" text-anchor="start" dominant-baseline="alphabetic"></text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 47.090652420539065 312.492439129 L 224.89225557066928 312.492439129" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray=""/><g transform="translate(227.61478945400262,312.492439129) translate(-227.61478945400262,-312.492439129)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 214.00212003733594 305.68610442066665 L 227.61478945400262 312.492439129 L 214.00212003733594 319.29877383733333 Z"/></g></g><g><g><rect fill="white" stroke="none" x="338.2823102910013" y="336.995244079" width="207.8481165319922" height="20.255652092"/></g><g><rect fill="white" stroke="none" x="338.2823102910013" y="353.33044737899996" width="174.2273432165625" height="16.988611432"/></g><text fill="black" stroke="none" font-family="monospace" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="340.7325907860013" y="351.69692704899995" text-anchor="start" dominant-baseline="alphabetic">GET /login/sso/redirect</text><text fill="black" stroke="none" font-family="monospace" font-size="8.8pt" font-style="normal" font-weight="normal" text-decoration="normal" x="340.7325907860013" y="364.765089689" text-anchor="start" dominant-baseline="alphabetic">?redirectUrl=&lt;clienturl&gt;</text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 243.9499927540026 370.319058811 L 637.7402104766588 370.319058811" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray=""/><g transform="translate(640.4627443599921,370.319058811) translate(-640.4627443599921,-370.319058811)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 626.8500749433255 363.51272410266665 L 640.4627443599921 370.319058811 L 626.8500749433255 377.12539351933333 Z"/></g></g><g><g><rect fill="white" stroke="none" x="676.127938231659" y="394.821863761" width="162.94903816285156" height="21.23576429"/></g><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="678.578218726659" y="409.52354673099995" text-anchor="start" dominant-baseline="alphabetic">Generate SAML request</text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 656.7979476599922 416.05762805099994 L 722.1387608599922 416.05762805099994 L 722.1387608599922 437.29339234099996 L 659.5204815433256 437.29339234099996" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray=""/><g transform="translate(656.7979476599922,437.29339234099996) translate(-656.7979476599922,-437.29339234099996)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 670.4106170766589 430.4870576326666 L 656.7979476599922 437.29339234099996 L 670.4106170766589 444.0997270493333 Z"/></g></g><g><g><rect fill="white" stroke="none" x="406.3465650517435" y="461.79619729099994" width="71.71960701050781" height="21.23576429"/></g><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="408.7968455467435" y="476.4978802609999" text-anchor="start" dominant-baseline="alphabetic">302 to IdP</text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 640.4627443599921 483.03196158099996 L 246.67252663733595 483.03196158099996" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray="6.53408132"/><g transform="translate(243.9499927540026,483.03196158099996) translate(-243.9499927540026,-483.03196158099996)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 257.5626621706693 476.2256268726666 L 243.9499927540026 483.03196158099996 L 257.5626621706693 489.8382962893333 Z"/></g></g><g><g><rect fill="white" stroke="none" x="361.75195085568225" y="507.5347665309999" width="393.1480585485937" height="20.255652092"/></g><g><rect fill="white" stroke="none" x="361.75195085568225" y="523.8699698309999" width="195.39319099488281" height="16.988611432"/></g><text fill="black" stroke="none" font-family="monospace" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="364.20223135068227" y="522.2364495009999" text-anchor="start" dominant-baseline="alphabetic">GET https:</text><text fill="black" stroke="none" font-family="monospace" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="452.4402989776354" y="522.2364495009999" text-anchor="start" dominant-baseline="alphabetic">//</text><text fill="black" stroke="none" font-family="monospace" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="470.087912503026" y="522.2364495009999" text-anchor="start" dominant-baseline="alphabetic">auth.mozilla.auth0.com/samlp/...</text><text fill="black" stroke="none" font-family="monospace" font-size="8.8pt" font-style="normal" font-weight="normal" text-decoration="normal" x="364.20223135068227" y="535.3046121409999" text-anchor="start" dominant-baseline="alphabetic">?SAMLRequest=&lt;SAML request&gt;</text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 243.9499927540026 540.8585812629999 L 869.9794336226224 540.8585812629999" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray=""/><g transform="translate(872.7019675059557,540.8585812629999) translate(-872.7019675059557,-540.8585812629999)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 859.0892980892891 534.0522465546666 L 872.7019675059557 540.8585812629999 L 859.0892980892891 547.6649159713332 Z"/></g></g><g><g><rect fill="white" stroke="none" x="509.4380012707213" y="565.3613862129998" width="97.77595771851563" height="21.23576429"/></g><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="511.88828176572133" y="580.0630691829998" text-anchor="start" dominant-baseline="alphabetic">200 login form</text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 872.7019675059557 586.5971505029999 L 246.67252663733595 586.5971505029999" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray="6.53408132"/><g transform="translate(243.9499927540026,586.5971505029999) translate(-243.9499927540026,-586.5971505029999)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 257.5626621706693 579.7908157946665 L 243.9499927540026 586.5971505029999 L 257.5626621706693 593.4034852113332 Z"/></g></g><g><g><rect fill="white" stroke="none" x="431.2403236584166" y="611.0999554529998" width="254.171312943125" height="21.23576429"/></g><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="433.69060415341664" y="625.8016384229998" text-anchor="start" dominant-baseline="alphabetic">submit login form with auth credentials</text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 243.9499927540026 632.3357197429998 L 869.9794336226224 632.3357197429998" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray=""/><g transform="translate(872.7019675059557,632.3357197429998) translate(-872.7019675059557,-632.3357197429998)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 859.0892980892891 625.5293850346665 L 872.7019675059557 632.3357197429998 L 859.0892980892891 639.1420544513331 Z"/></g></g><g><g><rect fill="white" stroke="none" x="362.81110918087757" y="656.8385246929997" width="391.0297418982031" height="21.23576429"/></g><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="365.2613896758776" y="671.5402076629997" text-anchor="start" dominant-baseline="alphabetic">200: auto-submitting HTML form including SAML Response</text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 872.7019675059557 678.0742889829997 L 246.67252663733595 678.0742889829997" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray="6.53408132"/><g transform="translate(243.9499927540026,678.0742889829997) translate(-243.9499927540026,-678.0742889829997)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 257.5626621706693 671.2679542746664 L 243.9499927540026 678.0742889829997 L 257.5626621706693 684.8806236913331 Z"/></g></g><g><g><rect fill="white" stroke="none" x="289.7513730961771" y="702.5770939329998" width="304.9099909216406" height="20.255652092"/></g><g><rect fill="white" stroke="none" x="289.7513730961771" y="718.9122972329998" width="167.17206062378906" height="16.988611432"/></g><text fill="black" stroke="none" font-family="monospace" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="292.2016535911771" y="717.2787769029998" text-anchor="start" dominant-baseline="alphabetic">POST /_matrix/saml2/authn_response</text><text fill="black" stroke="none" font-family="monospace" font-size="8.8pt" font-style="normal" font-weight="normal" text-decoration="normal" x="292.2016535911771" y="730.3469395429997" text-anchor="start" dominant-baseline="alphabetic">SAMLResponse=&lt;response&gt;</text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 243.9499927540026 735.9009086649997 L 637.7402104766588 735.9009086649997" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray=""/><g transform="translate(640.4627443599921,735.9009086649997) translate(-640.4627443599921,-735.9009086649997)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 626.8500749433255 729.0945739566664 L 640.4627443599921 735.9009086649997 L 626.8500749433255 742.7072433733331 Z"/></g></g><g><g><rect fill="white" stroke="none" x="676.127938231659" y="760.4037136149997" width="136.8640619665625" height="21.23576429"/></g><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="678.578218726659" y="775.1053965849997" text-anchor="start" dominant-baseline="alphabetic">Check if known user</text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 656.7979476599922 781.6394779049997 L 722.1387608599922 781.6394779049997 L 722.1387608599922 802.8752421949997 L 659.5204815433256 802.8752421949997" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray=""/><g transform="translate(656.7979476599922,802.8752421949997) translate(-656.7979476599922,-802.8752421949997)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 670.4106170766589 796.0689074866664 L 656.7979476599922 802.8752421949997 L 670.4106170766589 809.681576903333 Z"/></g></g><g><g><rect fill="white" stroke="none" x="311.7710874516458" y="827.3780471449998" width="158.06123603882813" height="20.255652092"/></g><g><rect fill="white" stroke="none" x="311.7710874516458" y="843.7132504449997" width="260.8705622107031" height="16.988611432"/></g><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="314.22136794664584" y="842.0797301149997" text-anchor="start" dominant-baseline="alphabetic">302 to username picker</text><text fill="black" stroke="none" font-family="sans-serif" font-size="8.8pt" font-style="normal" font-weight="normal" text-decoration="normal" x="314.22136794664584" y="855.1478927549997" text-anchor="start" dominant-baseline="alphabetic">including </text><text fill="black" stroke="none" font-family="monospace" font-size="8.8pt" font-style="normal" font-weight="normal" text-decoration="normal" x="363.73423110582553" y="855.1478927549997" text-anchor="start" dominant-baseline="alphabetic">username_mapping_session</text><text fill="black" stroke="none" font-family="sans-serif" font-size="8.8pt" font-style="normal" font-weight="normal" text-decoration="normal" x="533.061013332388" y="855.1478927549997" text-anchor="start" dominant-baseline="alphabetic"> cookie</text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 640.4627443599921 860.7018618769997 L 246.67252663733595 860.7018618769997" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray="6.53408132"/><g transform="translate(243.9499927540026,860.7018618769997) translate(-243.9499927540026,-860.7018618769997)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 257.5626621706693 853.8955271686664 L 243.9499927540026 860.7018618769997 L 257.5626621706693 867.508196585333 Z"/></g></g><g><g><rect fill="white" stroke="none" x="294.16328410691926" y="885.2046668269996" width="296.0861689001562" height="21.23576429"/></g><text fill="black" stroke="none" font-family="monospace" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="296.6135646019193" y="899.9063497969996" text-anchor="start" dominant-baseline="alphabetic">GET /_matrix/saml2/pick_username/</text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 243.9499927540026 906.4404311169997 L 637.7402104766588 906.4404311169997" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray=""/><g transform="translate(640.4627443599921,906.4404311169997) translate(-640.4627443599921,-906.4404311169997)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 626.8500749433255 899.6340964086663 L 640.4627443599921 906.4404311169997 L 626.8500749433255 913.246765825333 Z"/></g></g><g><g><rect fill="white" stroke="none" x="377.4312437016458" y="930.9432360669996" width="129.55024971070313" height="21.23576429"/></g><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="379.88152419664584" y="945.6449190369996" text-anchor="start" dominant-baseline="alphabetic">200 with form page</text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 640.4627443599921 952.1790003569996 L 246.67252663733595 952.1790003569996" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray="6.53408132"/><g transform="translate(243.9499927540026,952.1790003569996) translate(-243.9499927540026,-952.1790003569996)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 257.5626621706693 945.3726656486663 L 243.9499927540026 952.1790003569996 L 257.5626621706693 958.985335065333 Z"/></g></g><g><g><rect fill="white" stroke="none" x="272.1037748295755" y="976.6818053069996" width="340.2051874548437" height="20.255652092"/></g><g><rect fill="white" stroke="none" x="272.1037748295755" y="993.0170086069996" width="146.00621284546875" height="16.988611432"/></g><text fill="black" stroke="none" font-family="monospace" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="274.55405532457553" y="991.3834882769996" text-anchor="start" dominant-baseline="alphabetic">GET /_matrix/saml2/pick_username/check</text><text fill="black" stroke="none" font-family="monospace" font-size="8.8pt" font-style="normal" font-weight="normal" text-decoration="normal" x="274.55405532457553" y="1004.4516509169996" text-anchor="start" dominant-baseline="alphabetic">?username=&lt;username&gt;</text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 243.9499927540026 1010.0056200389996 L 637.7402104766588 1010.0056200389996" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray=""/><g transform="translate(640.4627443599921,1010.0056200389996) translate(-640.4627443599921,-1010.0056200389996)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 626.8500749433255 1003.1992853306663 L 640.4627443599921 1010.0056200389996 L 626.8500749433255 1016.811954747333 Z"/></g></g><g><g><rect fill="white" stroke="none" x="315.19585399217317" y="1034.5084249889994" width="254.02102912964844" height="20.255652092"/></g><g><rect fill="white" stroke="none" x="315.19585399217317" y="1050.8436282889995" width="154.2688471228125" height="16.988611432"/></g><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="317.6461344871732" y="1049.2101079589995" text-anchor="start" dominant-baseline="alphabetic">200 </text><text fill="black" stroke="none" font-family="monospace" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="346.1714335594388" y="1049.2101079589995" text-anchor="start" dominant-baseline="alphabetic">{"available": true/false}</text><text fill="black" stroke="none" font-family="sans-serif" font-size="8.8pt" font-style="normal" font-weight="normal" text-decoration="normal" x="317.6461344871732" y="1062.2782705989996" text-anchor="start" dominant-baseline="alphabetic">or 200 </text><text fill="black" stroke="none" font-family="monospace" font-size="8.8pt" font-style="normal" font-weight="normal" text-decoration="normal" x="354.1298991356107" y="1062.2782705989996" text-anchor="start" dominant-baseline="alphabetic">{"error": "..."}</text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 640.4627443599921 1067.8322397209995 L 246.67252663733595 1067.8322397209995" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray="6.53408132"/><g transform="translate(243.9499927540026,1067.8322397209995) translate(-243.9499927540026,-1067.8322397209995)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 257.5626621706693 1061.0259050126663 L 243.9499927540026 1067.8322397209995 L 257.5626621706693 1074.6385744293327 Z"/></g></g><g><g><rect fill="white" stroke="none" x="263.27998332566926" y="1092.3350446709994" width="357.8527704626562" height="20.255652092"/></g><g><rect fill="white" stroke="none" x="263.27998332566926" y="1108.6702479709995" width="138.95093025269531" height="16.988611432"/></g><text fill="black" stroke="none" font-family="monospace" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="265.7302638206693" y="1107.0367276409995" text-anchor="start" dominant-baseline="alphabetic">POST /_matrix/saml2/pick_username/submit</text><text fill="black" stroke="none" font-family="monospace" font-size="8.8pt" font-style="normal" font-weight="normal" text-decoration="normal" x="265.7302638206693" y="1120.1048902809996" text-anchor="start" dominant-baseline="alphabetic">username=&lt;username&gt;</text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 243.9499927540026 1125.6588594029995 L 637.7402104766588 1125.6588594029995" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray=""/><g transform="translate(640.4627443599921,1125.6588594029995) translate(-640.4627443599921,-1125.6588594029995)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 626.8500749433255 1118.8525246946663 L 640.4627443599921 1125.6588594029995 L 626.8500749433255 1132.4651941113327 Z"/></g></g><g><g><rect fill="white" stroke="none" x="314.2907865483255" y="1150.1616643529997" width="255.83116401734375" height="21.23576429"/></g><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="316.74106704332553" y="1164.8633473229997" text-anchor="start" dominant-baseline="alphabetic">302 to original clienturl with loginToken</text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 640.4627443599921 1171.3974286429996 L 246.67252663733595 1171.3974286429996" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray="6.53408132"/><g transform="translate(243.9499927540026,1171.3974286429996) translate(-243.9499927540026,-1171.3974286429996)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 257.5626621706693 1164.5910939346663 L 243.9499927540026 1171.3974286429996 L 257.5626621706693 1178.2037633513328 Z"/></g></g><g><g><rect fill="white" stroke="none" x="134.90244044227086" y="1195.9002335929997" width="4.90056099" height="21.23576429"/></g><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="137.35272093727085" y="1210.6019165629998" text-anchor="start" dominant-baseline="alphabetic"></text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 227.61478945400262 1217.1359978829996 L 49.8131863038724 1217.1359978829996" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray=""/><g transform="translate(47.090652420539065,1217.1359978829996) translate(-47.090652420539065,-1217.1359978829996)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 60.703321837205735 1210.3296631746664 L 47.090652420539065 1217.1359978829996 L 60.703321837205735 1223.9423325913328 Z"/></g></g><g><g><rect fill="white" stroke="none" x="182.58255192846875" y="1241.6388028329995" width="101.96243537964844" height="20.255652092"/></g><g><rect fill="white" stroke="none" x="182.58255192846875" y="1257.9740061329996" width="322.3882929235937" height="16.988611432"/></g><text fill="black" stroke="none" font-family="monospace" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="185.03283242346873" y="1256.3404858029996" text-anchor="start" dominant-baseline="alphabetic">POST /login</text><text fill="black" stroke="none" font-family="monospace" font-size="8.8pt" font-style="normal" font-weight="normal" text-decoration="normal" x="185.03283242346873" y="1269.4086484429997" text-anchor="start" dominant-baseline="alphabetic">{"type": "m.login.token", "token": "&lt;token&gt;"}</text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 47.090652420539065 1274.9626175649996 L 637.7402104766588 1274.9626175649996" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray=""/><g transform="translate(640.4627443599921,1274.9626175649996) translate(-640.4627443599921,-1274.9626175649996)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 626.8500749433255 1268.1562828566664 L 640.4627443599921 1274.9626175649996 L 626.8500749433255 1281.7689522733328 Z"/></g></g><g><g><rect fill="white" stroke="none" x="276.57198521704294" y="1299.4654225149998" width="134.40942634644531" height="21.23576429"/></g><text fill="black" stroke="none" font-family="monospace" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="279.02226571204295" y="1314.1671054849999" text-anchor="start" dominant-baseline="alphabetic">access token</text><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="384.9079468643867" y="1314.1671054849999" text-anchor="start" dominant-baseline="alphabetic"> etc</text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 640.4627443599921 1320.7011868049997 L 49.8131863038724 1320.7011868049997" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray="6.53408132"/><g transform="translate(47.090652420539065,1320.7011868049997) translate(-47.090652420539065,-1320.7011868049997)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 60.703321837205735 1313.8948520966665 L 47.090652420539065 1320.7011868049997 L 60.703321837205735 1327.507521513333 Z"/></g></g></g><g/></g></svg>
\ No newline at end of file
diff --git a/doc/login_flow.txt b/doc/login_flow.txt
new file mode 100644
index 0000000..6520f7e
--- /dev/null
+++ b/doc/login_flow.txt
@@ -0,0 +1,65 @@
+
+title Mozilla matrix login flow
+
+participant Riot
+participant "(Embedded) Browser" as B
+participant "Synapse" as HS
+participant "SAML2 IdP" as IdP
+
+activate Riot
+
+Riot->HS:""GET /login
+activate HS
+Riot<--HS:"""type":"m.login.sso"
+deactivate HS
+
+create B
+Riot->B:
+activate B
+B->HS:""GET /login/sso/redirect\n--?redirectUrl=<clienturl>--""
+activate HS
+HS->HS:Generate SAML request
+B<--HS:302 to IdP
+deactivate HS
+
+
+B->IdP: ""GET https://auth.mozilla.auth0.com/samlp/...\n--?SAMLRequest=<SAML request>
+activate IdP
+B<--IdP: 200 login form
+deactivate IdP
+B->IdP: submit login form with auth credentials
+activate IdP
+IdP-->B:200: auto-submitting HTML form including SAML Response
+deactivate IdP
+
+B->HS:""POST /_matrix/saml2/authn_response\n--SAMLResponse=<response>
+activate HS
+HS->HS:Check if known user
+B<--HS:302 to username picker\n--including ""username_mapping_session"" cookie
+deactivate HS
+
+B->HS:""GET /_matrix/saml2/pick_username/
+activate HS
+B<--HS: 200 with form page
+deactivate HS
+
+B->HS:""GET /_matrix/saml2/pick_username/check\n--?username=<username>
+activate HS
+B<--HS:200 ""{"available": true/false}""\n--or 200 ""{"error": "..."}""
+deactivate HS
+
+B->HS:""POST /_matrix/saml2/pick_username/submit\n--username=<username>
+activate HS
+B<--HS:302 to original clienturl with loginToken
+deactivate HS
+Riot<-B:
+deactivate B
+
+destroysilent B
+
+Riot->HS: ""POST /login\n--{"type": "m.login.token", "token": "<token>"}
+activate HS
+Riot<--HS:""access token"" etc
+deactivate HS
+
+
diff --git a/matrix_synapse_saml_mozilla/__init__.py b/matrix_synapse_saml_mozilla/__init__.py
new file mode 100644
index 0000000..4da96ef
--- /dev/null
+++ b/matrix_synapse_saml_mozilla/__init__.py
@@ -0,0 +1,21 @@
+# -*- coding: utf-8 -*-
+# Copyright 2019 The Matrix.org Foundation C.I.C.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from matrix_synapse_saml_mozilla.mapping_provider import SamlMappingProvider
+from matrix_synapse_saml_mozilla.username_picker import pick_username_resource
+
+__version__ = "0.0.1"
+
+__all__ = ["SamlMappingProvider", "pick_username_resource"]
diff --git a/matrix_synapse_saml_mozilla/_sessions.py b/matrix_synapse_saml_mozilla/_sessions.py
new file mode 100644
index 0000000..0c9bf79
--- /dev/null
+++ b/matrix_synapse_saml_mozilla/_sessions.py
@@ -0,0 +1,65 @@
+# -*- coding: utf-8 -*-
+# Copyright 2020 The Matrix.org Foundation C.I.C.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+import logging
+from typing import Optional
+
+import attr
+import time
+
+
+SESSION_COOKIE_NAME = b"username_mapping_session"
+
+logger = logging.getLogger(__name__)
+
+
+@attr.s
+class UsernameMappingSession:
+    """Data we track about SAML2 sessions"""
+
+    # user ID on the SAML server
+    remote_user_id = attr.ib(type=str)
+
+    # displayname, per the SAML attributes
+    displayname = attr.ib(type=Optional[str])
+
+    # where to redirect the client back to
+    client_redirect_url = attr.ib(type=str)
+
+    # expiry time for the session, in milliseconds
+    expiry_time_ms = attr.ib(type=int)
+
+
+# a map from session id to session data
+username_mapping_sessions = {}  # type: dict[str, UsernameMappingSession]
+
+
+def expire_old_sessions(gettime=time.time):
+    """Delete any sessions which have passed their expiry_time"""
+    to_expire = []
+    now = int(gettime() * 1000)
+
+    for session_id, session in username_mapping_sessions.items():
+        if session.expiry_time_ms <= now:
+            to_expire.append(session_id)
+
+    for session_id in to_expire:
+        logger.info("Expiring mapping session %s", session_id)
+        del username_mapping_sessions[session_id]
+
+
+def get_mapping_session(session_id: str) -> Optional[UsernameMappingSession]:
+    """Look up the given session id, first expiring any old sessions"""
+    expire_old_sessions()
+    return username_mapping_sessions.get(session_id, None)
diff --git a/matrix_synapse_saml_mozilla.py b/matrix_synapse_saml_mozilla/mapping_provider.py
similarity index 50%
rename from matrix_synapse_saml_mozilla.py
rename to matrix_synapse_saml_mozilla/mapping_provider.py
index ac1dbed..4392d91 100644
--- a/matrix_synapse_saml_mozilla.py
+++ b/matrix_synapse_saml_mozilla/mapping_provider.py
@@ -1,5 +1,5 @@
 # -*- coding: utf-8 -*-
-# Copyright 2019 The Matrix.org Foundation C.I.C.
+# Copyright 2020 The Matrix.org Foundation C.I.C.
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -12,100 +12,94 @@
 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 # See the License for the specific language governing permissions and
 # limitations under the License.
-
+import logging
+import random
+import string
+import time
 from typing import Tuple
-import re
+
 import attr
-import string
 import saml2.response
 
-__version__ = "0.0.1"
+import synapse.module_api
+from synapse.module_api.errors import RedirectException
+
+from matrix_synapse_saml_mozilla._sessions import (
+    UsernameMappingSession,
+    username_mapping_sessions,
+    expire_old_sessions,
+    SESSION_COOKIE_NAME,
+)
+
+logger = logging.getLogger(__name__)
+
+
+MAPPING_SESSION_VALIDITY_PERIOD_MS = 15 * 60 * 1000
 
 
 @attr.s
 class SamlConfig(object):
-    mxid_source_attribute = attr.ib()
+    pass
 
 
 class SamlMappingProvider(object):
-    def __init__(self, parsed_config: SamlConfig):
+    def __init__(
+        self, parsed_config: SamlConfig, module_api: synapse.module_api.ModuleApi
+    ):
         """A Mozilla-flavoured, Synapse user mapping provider
 
         Args:
             parsed_config: A configuration object. The result of self.parse_config
         """
-        self._mxid_source_attribute = parsed_config.mxid_source_attribute
-
-        mxid_localpart_allowed_characters = set(
-            "_-./=" + string.ascii_lowercase + string.digits
-        )
-        self._dot_replace_pattern = re.compile(
-            ("[^%s]" % (re.escape("".join(mxid_localpart_allowed_characters)),))
-        )
-        self._multiple_to_single_dot_pattern = re.compile(r"\.{2,}")
-        self._string_end_dot_pattern = re.compile(r"\.$")
+        self._random = random.SystemRandom()
 
     def saml_response_to_user_attributes(
-            self,
-            saml_response: saml2.response.AuthnResponse,
-            failures: int = 0,
+        self,
+        saml_response: saml2.response.AuthnResponse,
+        failures: int,
+        client_redirect_url: str,
     ) -> dict:
         """Maps some text from a SAML response to attributes of a new user
-
         Args:
             saml_response: A SAML auth response object
 
             failures: How many times a call to this function with this
                 saml_response has resulted in a failure
 
+            client_redirect_url: where the client wants to redirect back to
+
         Returns:
             dict: A dict containing new user attributes. Possible keys:
                 * mxid_localpart (str): Required. The localpart of the user's mxid
                 * displayname (str): The displayname of the user
         """
-        # The calling function will catch the KeyError if this fails
-        mxid_source = saml_response.ava[self._mxid_source_attribute][0]
-
-        # Truncate the username to the first found '@' character to prevent complete
-        # emails being leaked
-        pos = mxid_source.find("@")
-        if pos >= 0:
-            mxid_source = mxid_source[:pos]
-        mxid_localpart = self._dotreplace_for_mxid(mxid_source)
-
-        # Append suffix integer if last call to this function failed to produce
-        # a usable mxid
-        localpart = mxid_localpart + (str(failures) if failures else "")
-
-        # Retrieve the display name from the saml response
+        remote_user_id = saml_response.ava["uid"][0]
         displayname = saml_response.ava.get("displayName", [None])[0]
 
-        return {
-            "mxid_localpart": localpart,
-            "displayname": displayname,
-        }
-
-    def _dotreplace_for_mxid(self, username: str) -> str:
-        """Replace non-allowed mxid characters with a '.'
-
-        Args:
-            username (str): The username to process
+        expire_old_sessions()
 
-        Returns:
-            str: The processed username
-        """
-        username = username.lower()
-        username = self._dot_replace_pattern.sub(".", username)
+        # make up a cryptorandom session id
+        session_id = "".join(
+            self._random.choice(string.ascii_letters) for _ in range(16)
+        )
 
-        # regular mxids aren't allowed to start with an underscore either
-        username = re.sub("^_", "", username)
+        now = int(time.time() * 1000)
+        session = UsernameMappingSession(
+            remote_user_id=remote_user_id,
+            displayname=displayname,
+            client_redirect_url=client_redirect_url,
+            expiry_time_ms=now + MAPPING_SESSION_VALIDITY_PERIOD_MS,
+        )
 
-        # Change all instances of multiple dots together into a single dot
-        username = self._multiple_to_single_dot_pattern.sub(".", username)
+        username_mapping_sessions[session_id] = session
+        logger.info("Recorded registration session id %s", session_id)
 
-        # Remove any trailing dots
-        username = self._string_end_dot_pattern.sub("", username)
-        return username
+        # Redirect to the username picker
+        e = RedirectException(b"/_matrix/saml2/pick_username/")
+        e.cookies.append(
+            b"%s=%s; path=/" % (SESSION_COOKIE_NAME, session_id.encode("ascii"),)
+        )
+        raise e
 
     @staticmethod
     def parse_config(config: dict) -> SamlConfig:
@@ -115,8 +109,7 @@ class SamlMappingProvider(object):
         Returns:
             SamlConfig: A custom config object
         """
-        mxid_source_attribute = config.get("mxid_source_attribute", "uid")
-        return SamlConfig(mxid_source_attribute)
+        return SamlConfig()
 
     @staticmethod
     def get_saml_attributes(config: SamlConfig) -> Tuple[set, set]:
@@ -131,4 +124,4 @@ class SamlMappingProvider(object):
                 second set consists of those attributes which can be used if
                 available, but are not necessary
         """
-        return {"uid", config.mxid_source_attribute}, {"displayName"}
+        return {"uid"}, {"displayName"}
diff --git a/matrix_synapse_saml_mozilla/res/index.html b/matrix_synapse_saml_mozilla/res/index.html
new file mode 100644
index 0000000..c6463a8
--- /dev/null
+++ b/matrix_synapse_saml_mozilla/res/index.html
@@ -0,0 +1,20 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <title>Synapse Login</title>
+    <link rel="stylesheet" href="style.css" type="text/css" />
+  </head>
+  <body>
+    <div class="card">
+      <form method="post" class="form__input" id="form" action="submit">
+        <input type="text" name="username" id="field-username" autofocus="">
+        <label for="field-username">
+          <span><span aria-hidden="true">Please pick your </span>username</span>
+        </label>
+        <input type="button" class="button button--full-width" id="button-submit" value="Submit">
+      </form>
+      <div role=alert class="tooltip hidden" id="message"></div>
+      <script src="script.js"></script>
+    </div>
+  </body>
+</html>
diff --git a/matrix_synapse_saml_mozilla/res/script.js b/matrix_synapse_saml_mozilla/res/script.js
new file mode 100644
index 0000000..1fa4e39
--- /dev/null
+++ b/matrix_synapse_saml_mozilla/res/script.js
@@ -0,0 +1,117 @@
+let inputField = document.getElementById("field-username");
+let inputForm = document.getElementById("form");
+let submitButton = document.getElementById("button-submit");
+let message = document.getElementById("message");
+
+// Remove input field placeholder if the text field is not empty
+let switchClass = function(input) {
+  if (input.value.length > 0) {
+    input.classList.add('has-contents');
+  }
+  else {
+    input.classList.remove('has-contents');
+  }
+};
+
+// Submit username and receive response
+let showMessage = function(messageText) {
+  // Unhide the message text
+  message.classList.remove("hidden");
+
+  message.innerHTML = messageText;
+};
+
+let onResponse = function(response, success) {
+  // Display message
+  showMessage(response);
+
+  if(success) {
+    inputForm.submit();
+    return;
+  }
+
+  // Enable submit button and input field
+  submitButton.classList.remove('button--disabled');
+  submitButton.value = "Submit"
+};
+
+// We allow upper case characters here, but then lowercase before sending to the server
+let allowedUsernameCharacters = RegExp("[^a-zA-Z0-9\\.\\_\\=\\-\\/]");
+let usernameIsValid = function(username) {
+  return !allowedUsernameCharacters.test(username);
+}
+let allowedCharactersString = "" +
+"<code>a-z</code>, " +
+"<code>0-9</code>, " +
+"<code>.</code>, " +
+"<code>_</code>, " +
+"<code>-</code>, " +
+"<code>/</code>, " +
+"<code>=</code>";
+
+let buildQueryString = function(params) {
+    return Object.keys(params)
+        .map(k => encodeURIComponent(k) + '=' + encodeURIComponent(params[k]))
+        .join('&');
+}
+
+let submitUsername = function(username) {
+  if(username.length == 0) {
+    onResponse("Please enter a username.", false);
+    return;
+  }
+  if(!usernameIsValid(username)) {
+    onResponse("Invalid username. Only the following characters are allowed: " + allowedCharactersString, false);
+    return;
+  }
+
+    let check_uri = 'check?' + buildQueryString({"username": username});
+    fetch(check_uri, {
+        "credentials": "include",
+    }).then((response) => {
+        if(!response.ok) {
+            // for non-200 responses, raise the body of the response as an exception
+            return response.text().then((text) => { throw text });
+        } else {
+            return response.json()
+        }
+    }).then((json) => {
+        if(json.error) {
+            throw json.error;
+        } else if(json.available) {
+            onResponse("Success. Please wait a moment for your browser to redirect.", true);
+        } else {
+            onResponse("This username is not available, please choose another.", false);
+        }
+    }).catch((err) => {
+        onResponse("Error checking username availability: " + err, false);
+    });
+}
+
+let clickSubmit = function() {
+  if(submitButton.classList.contains('button--disabled')) { return; }
+
+  // Disable submit button and input field
+  submitButton.classList.add('button--disabled');
+
+  // Submit username
+  submitButton.value = "Checking...";
+  submitUsername(inputField.value);
+};
+
+submitButton.onclick = clickSubmit;
+
+// Listen for events on inputField
+inputField.addEventListener('keypress', function(event) {
+  // Listen for Enter on input field
+  if(event.which === 13) {
+    event.preventDefault();
+    clickSubmit();
+    return true;
+  }
+  switchClass(inputField);
+});
+inputField.addEventListener('change', function() {
+  switchClass(inputField);
+});
+
diff --git a/matrix_synapse_saml_mozilla/res/style.css b/matrix_synapse_saml_mozilla/res/style.css
new file mode 100644
index 0000000..45377f8
--- /dev/null
+++ b/matrix_synapse_saml_mozilla/res/style.css
@@ -0,0 +1,178 @@
+body {
+  background: #ededf0;
+  color: #737373;
+  font-family: "Open Sans", sans-serif;
+  letter-spacing: 0.03em;
+  margin: 0;
+  padding: 0;
+  display: grid;
+  grid-template-rows: auto 1fr; }
+
+.card {
+  background-color: #fff;
+  padding: 2em;
+  position: relative;
+  margin: auto;
+  width: 100%;
+  box-shadow: 0 0.25em 0.25em 0 rgba(210, 210, 210, 0.5);
+  border-radius: 0.125em; }
+  @media (min-width: 25em) {
+    .card {
+      max-width: 26em;
+      padding: 2.5em; } }
+  @supports (display: grid) {
+    .card {
+      grid-row-start: 2; }
+      @media (min-height: 50em) {
+        .card {
+          top: -3em;
+          /* compensate for negative margin for footer links */ } } }
+  .card__back {
+    margin-bottom: 1em; }
+  .card__heading {
+    font-size: 1.4em;
+    font-weight: 400;
+    text-transform: capitalize;
+    padding-left: 2.125em;
+    position: relative;
+    min-height: 1.5em; }
+    .card__heading--iconless {
+      padding-left: 0; }
+    .card__heading img {
+      width: 1.5em;
+      height: 1.5em;
+      position: absolute;
+      left: 0;
+      top: 0; }
+    .card__heading--success {
+      color: #12bc00; }
+    .card__heading--error {
+      color: #ff0039; }
+  .card [data-screen]:focus {
+    outline: none; }
+
+* {
+  box-sizing: border-box; }
+
+form {
+  margin: 0; }
+  form * {
+    font-family: inherit; }
+
+label {
+  margin: 2em 0;
+  display: block; }
+
+input[type="text"],
+input[type="email"],
+input[type="password"] {
+  font-size: 100%;
+  background-color: #ededf0;
+  border: 1px solid #fff;
+  border-radius: .2em;
+  padding: .5em .9em;
+  display: block;
+  width: 100%;
+  margin-bottom: 1em; }
+  input[type="text"]:hover, input[type="text"]:focus,
+  input[type="email"]:hover,
+  input[type="email"]:focus,
+  input[type="password"]:hover,
+  input[type="password"]:focus {
+    border: 1px solid #0060df;
+    outline: none; }
+  .focus-styles input[type="text"]:focus, .focus-styles
+  input[type="email"]:focus, .focus-styles
+  input[type="password"]:focus {
+    border-color: transparent; }
+
+.form__input {
+  position: relative; }
+  p + .form__input {
+    margin-top: 2.5em;
+    /* leave space to fit a paragraph above a field */ }
+  .form__input label {
+    margin: 0;
+    position: absolute;
+    top: .5em;
+    left: .9em; }
+  .form__input input:focus + label,
+  .form__input input.has-contents + label {
+    position: absolute;
+    top: -1.5em;
+    color: #0060df;
+    font-weight: bold; }
+    .form__input input:focus + label > span,
+    .form__input input.has-contents + label > span {
+      font-size: 0.75em; }
+
+html,
+body {
+  height: 100%; }
+
+.button {
+  text-align: center;
+  text-decoration: none;
+  padding: .93em 2em;
+  display: block;
+  font-size: 87.5%;
+  letter-spacing: .04em;
+  line-height: 1.57;
+  font-family: inherit;
+  border-radius: 2em;
+  background-color: #0060df;
+  color: #fff;
+  border: 1px solid transparent;
+  transition: background-color .1s ease-in-out;
+  -webkit-appearance: none;
+  -moz-appearance: none;
+  appearance: none; }
+  .button:hover {
+    background-color: #fff;
+    color: #0060df;
+    border-color: currentColor;
+    text-decoration: none; }
+  .button:active {
+    background-color: #0060df;
+    color: #fff;
+    border-color: #0060df; }
+  .button--full-width {
+    width: 100%; }
+  .button--secondary {
+    border-color: #b1b1b3;
+    background-color: transparent;
+    color: #000;
+    text-transform: none; }
+    .button--secondary:hover {
+      background-color: #000;
+      color: #fff;
+      border-color: transparent; }
+      .button--secondary:hover svg > path {
+        fill: #fff; }
+    .button--secondary:active {
+      background-color: transparent;
+      border-color: #000;
+      color: #000; }
+  .button--disabled {
+    border-color: #fff;
+    background-color: transparent;
+    color: #000;
+    text-transform: none; }
+    .button--disabled:hover {
+      background-color: #fff;
+      color: #000;
+      border-color: transparent; }
+
+.hidden {
+  display: none; }
+
+.tooltip {
+  background-color: #f9f9fa;
+  padding: 1em;
+  margin: 1em 0; }
+  .tooltip p:last-child {
+    margin-bottom: 0; }
+  .tooltip a:last-child {
+    margin-left: .5em; }
+  .tooltip:target {
+    display: block; }
diff --git a/matrix_synapse_saml_mozilla/username_picker.py b/matrix_synapse_saml_mozilla/username_picker.py
new file mode 100644
index 0000000..0d7a16f
--- /dev/null
+++ b/matrix_synapse_saml_mozilla/username_picker.py
@@ -0,0 +1,270 @@
+# -*- coding: utf-8 -*-
+# Copyright 2020 The Matrix.org Foundation C.I.C.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+import html
+import json
+import logging
+import urllib.parse
+from typing import Any
+
+import pkg_resources
+from twisted.web.resource import Resource
+from twisted.web.server import NOT_DONE_YET, Request
+from twisted.web.static import File
+
+import synapse.module_api
+from synapse.module_api import run_in_background
+from synapse.module_api.errors import SynapseError
+
+from matrix_synapse_saml_mozilla._sessions import (
+    get_mapping_session,
+    username_mapping_sessions,
+    SESSION_COOKIE_NAME,
+)
+
+"""
+This file implements the "username picker" resource, which is mapped as an
+additional_resource into the synapse resource tree.
+
+The top-level resource is just a File resource which serves up the static files in the
+"res" directory, but it has a couple of children:
+
+   * "submit", which does the mechanics of registering the new user, and redirects the
+     browser back to the client URL
+
+    * "check" (TODO): checks if a userid is free.
+"""
+
+logger = logging.getLogger(__name__)
+
+
+def pick_username_resource(
+    parsed_config, module_api: synapse.module_api.ModuleApi
+) -> Resource:
+    """Factory method to generate the top-level username picker resource"""
+    base_path = pkg_resources.resource_filename("matrix_synapse_saml_mozilla", "res")
+    res = File(base_path)
+    res.putChild(b"submit", SubmitResource(module_api))
+    res.putChild(b"check", AvailabilityCheckResource(module_api))
+    return res
+
+
+def parse_config(config: dict):
+    return None
+
+
+pick_username_resource.parse_config = parse_config
+
+
+HTML_ERROR_TEMPLATE = """<!DOCTYPE html>
+<html lang=en>
+  <head>
+    <meta charset="utf-8">
+    <title>Error {code}</title>
+  </head>
+  <body>
+     <p>{msg}</p>
+  </body>
+</html>
+"""
+
+
+def _wrap_for_html_exceptions(f):
+    async def wrapped(self, request):
+        try:
+            return await f(self, request)
+        except Exception:
+            logger.exception("Error handling request %s" % (request,))
+            _return_html_error(500, "Internal server error", request)
+
+    return wrapped
+
+
+def _wrap_for_text_exceptions(f):
+    async def wrapped(self, request):
+        try:
+            return await f(self, request)
+        except Exception:
+            logger.exception("Error handling request %s" % (request,))
+            body = b"Internal server error"
+            request.setResponseCode(500)
+            request.setHeader(b"Content-Type", b"text/plain; charset=utf-8")
+            request.setHeader(b"Content-Length", b"%i" % (len(body),))
+            request.write(body)
+            request.finish()
+
+    return wrapped
+
+
+class AsyncResource(Resource):
+    """Extends twisted.web.Resource to add support for async_render_X methods"""
+
+    def render(self, request: Request):
+        method = request.method.decode("ascii")
+        m = getattr(self, "async_render_" + method, None)
+        if not m and method == "HEAD":
+            m = getattr(self, "async_render_GET", None)
+        if not m:
+            return super().render(request)
+
+        async def run():
+            with request.processing():
+                return await m(request)
+
+        run_in_background(run)
+        return NOT_DONE_YET
+
+
+class SubmitResource(AsyncResource):
+    def __init__(self, module_api: synapse.module_api.ModuleApi):
+        super().__init__()
+        self._module_api = module_api
+
+    @_wrap_for_html_exceptions
+    async def async_render_POST(self, request: Request):
+        session_id = request.getCookie(SESSION_COOKIE_NAME)
+        if not session_id:
+            _return_html_error(400, "missing session_id", request)
+            return
+
+        session_id = session_id.decode("ascii", errors="replace")
+        session = get_mapping_session(session_id)
+        if not session:
+            logger.info("Session ID %s not found", session_id)
+            _return_html_error(403, "Unknown session", request)
+            return
+
+        # we don't clear the session from the dict until the ID is successfully
+        # registered, so the user can go round and have another go if need be.
+        #
+        # this means there's theoretically a race where a single user can register
+        # two accounts. I'm going to assume that's not a dealbreaker.
+
+        if b"username" not in request.args:
+            _return_html_error(400, "missing username", request)
+            return
+        localpart = request.args[b"username"][0].decode("utf-8", errors="replace")
+        logger.info("Registering username %s", localpart)
+        try:
+            registered_user_id = await self._module_api.register_user(
+                localpart=localpart, displayname=session.displayname
+            )
+        except SynapseError as e:
+            logger.warning("Error during registration: %s", e)
+            _return_html_error(e.code, e.msg, request)
+            return
+
+        await self._module_api.record_user_external_id(
+            "saml", session.remote_user_id, registered_user_id
+        )
+
+        del username_mapping_sessions[session_id]
+
+        login_token = self._module_api.generate_short_term_login_token(
+            registered_user_id
+        )
+        redirect_url = _add_login_token_to_redirect_url(
+            session.client_redirect_url, login_token
+        )
+
+        # delete the cookie
+        request.addCookie(
+            SESSION_COOKIE_NAME,
+            b"",
+            expires=b"Thu, 01 Jan 1970 00:00:00 GMT",
+            path=b"/",
+        )
+        request.redirect(redirect_url)
+        request.finish()
+
+
+class AvailabilityCheckResource(AsyncResource):
+    def __init__(self, module_api: synapse.module_api.ModuleApi):
+        super().__init__()
+        self._module_api = module_api
+
+    @_wrap_for_text_exceptions
+    async def async_render_GET(self, request: Request):
+        # make sure that there is a valid mapping session, to stop people dictionary-
+        # scanning for accounts
+        session_id = request.getCookie(SESSION_COOKIE_NAME)
+        if not session_id:
+            _return_json({"error": "missing session_id"}, request)
+            return
+
+        session_id = session_id.decode("ascii", errors="replace")
+        session = get_mapping_session(session_id)
+        if not session:
+            logger.info("Couldn't find session id %s", session_id)
+            _return_json({"error": "unknown session"}, request)
+            return
+
+        if b"username" not in request.args:
+            _return_json({"error": "missing username"}, request)
+            return
+        localpart = request.args[b"username"][0].decode("utf-8", errors="replace")
+        logger.info("Checking for availability of username %s", localpart)
+        try:
+            user_id = self._module_api.get_qualified_user_id(localpart)
+            registered_id = await self._module_api.check_user_exists(user_id)
+            available = registered_id is None
+        except Exception as e:
+            logger.warning(
+                "Error checking for availability of %s: %s %s" % (localpart, type(e), e)
+            )
+            available = False
+        response = {"available": available}
+        _return_json(response, request)
+
+
+def _add_login_token_to_redirect_url(url, token):
+    url_parts = list(urllib.parse.urlparse(url))
+    query = dict(urllib.parse.parse_qsl(url_parts[4]))
+    query.update({"loginToken": token})
+    url_parts[4] = urllib.parse.urlencode(query)
+    return urllib.parse.urlunparse(url_parts)
+
+
+def _return_html_error(code: int, msg: str, request: Request):
+    """Sends an HTML error page"""
+    body = HTML_ERROR_TEMPLATE.format(code=code, msg=html.escape(msg)).encode("utf-8")
+    request.setResponseCode(code)
+    request.setHeader(b"Content-Type", b"text/html; charset=utf-8")
+    request.setHeader(b"Content-Length", b"%i" % (len(body),))
+    request.write(body)
+    try:
+        request.finish()
+    except RuntimeError as e:
+        logger.info("Connection disconnected before response was written: %r", e)
+
+
+def _return_json(json_obj: Any, request: Request):
+    json_bytes = json.dumps(json_obj).encode("utf-8")
+
+    request.setHeader(b"Content-Type", b"application/json")
+    request.setHeader(b"Content-Length", b"%d" % (len(json_bytes),))
+    request.setHeader(b"Cache-Control", b"no-cache, no-store, must-revalidate")
+    request.setHeader(b"Access-Control-Allow-Origin", b"*")
+    request.setHeader(
+        b"Access-Control-Allow-Methods", b"GET, POST, PUT, DELETE, OPTIONS"
+    )
+    request.setHeader(
+        b"Access-Control-Allow-Headers",
+        b"Origin, X-Requested-With, Content-Type, Accept, Authorization",
+    )
+    request.write(json_bytes)
+    try:
+        request.finish()
+    except RuntimeError as e:
+        logger.info("Connection disconnected before response was written: %r", e)
diff --git a/setup.cfg b/setup.cfg
index ab5366c..f3d6356 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -2,3 +2,15 @@
 max-line-length = 90
 #  W503 requires that binary operators be at the end, not start, of lines. Erik doesn't like it.
 ignore = W503
+
+[isort]
+line_length = 88
+not_skip = __init__.py
+sections = FUTURE,STDLIB,THIRDPARTY,SYNAPSE,FIRSTPARTY,TESTS,LOCALFOLDER
+default_section = THIRDPARTY
+known_synapse = synapse
+known_first_party = matrix_synapse_saml_mozilla
+known_tests = tests
+multi_line_output = 3
+include_trailing_comma = true
+combine_as_imports = true
diff --git a/setup.py b/setup.py
index 5e70e50..e15a6c4 100755
--- a/setup.py
+++ b/setup.py
@@ -34,25 +34,22 @@ def exec_file(path_segments, name):
     the constant and executing it."""
     result = {}
     code = read_file(path_segments)
-    lines = [line for line in code.split('\n') if line.startswith(name)]
+    lines = [line for line in code.split("\n") if line.startswith(name)]
     exec("\n".join(lines), result)
     return result[name]
 
 
 setup(
     name="matrix-synapse-saml-mozilla",
-    version=exec_file(("matrix_synapse_saml_mozilla.py",), "__version__"),
+    version=exec_file(("matrix_synapse_saml_mozilla/__init__.py",), "__version__"),
     py_modules=["matrix-synapse-saml-mozilla"],
     description="An Mozilla-flavoured SAML MXID mapper for Synapse",
-    install_requires=[
-        "attr>=0.3.1",
-        "pysaml2>=4.5.0",
-    ],
+    install_requires=["attr>=0.3.1", "pysaml2>=4.5.0"],
     long_description=read_file(("README.md",)),
     long_description_content_type="text/markdown",
     classifiers=[
-        'Development Status :: 4 - Beta',
-        'License :: OSI Approved :: Apache Software License',
-        'Programming Language :: Python :: 3',
+        "Development Status :: 4 - Beta",
+        "License :: OSI Approved :: Apache Software License",
+        "Programming Language :: Python :: 3",
     ],
 )
diff --git a/tests/__init__.py b/tests/__init__.py
index c04c70f..4bd8d25 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -1,16 +1,15 @@
 from typing import Tuple
+
 from matrix_synapse_saml_mozilla import SamlMappingProvider
 
 
 def create_mapping_provider() -> Tuple[SamlMappingProvider, dict]:
     # Default configuration
-    config_dict = {
-        "mxid_source_attribute": "uid"
-    }
+    config_dict = {}
 
     # Convert the config dictionary to a SamlMappingProvider.SamlConfig object
     config = SamlMappingProvider.parse_config(config_dict)
 
     # Create a new instance of the provider with the specified config
     # Return the config dict as well for other test methods to use
-    return SamlMappingProvider(config), config_dict
+    return SamlMappingProvider(config, None), config_dict
diff --git a/tests/test_attributes.py b/tests/test_attributes.py
index e599f6d..a89af77 100644
--- a/tests/test_attributes.py
+++ b/tests/test_attributes.py
@@ -14,103 +14,58 @@
 # limitations under the License.
 
 import logging
+import re
+import time
 import unittest
-from typing import Optional
 
-from . import create_mapping_provider
-
-logging.basicConfig()
+from synapse.api.errors import RedirectException
 
+from matrix_synapse_saml_mozilla._sessions import username_mapping_sessions
 
-def _make_test_saml_response(
-    provider_config: dict,
-    source_attribute_value: str,
-    display_name: Optional[str] = None
-):
-    """Create a fake object based off of saml2.response.AuthnResponse
-
-    Args:
-        provider_config: The config dictionary used when creating the provider object
-        source_attribute_value: The desired value that the mapping provider will
-            pull out of the response object to turn into a Matrix UserID.
-        display_name: The desired displayname that the mapping provider will pull
-            out of the response object to turn into a Matrix user displayname.
-
-    Returns:
-        An object masquerading as a saml2.response.AuthnResponse object
-    """
+from . import create_mapping_provider
 
-    class FakeResponse(object):
+logging.basicConfig()
 
-        def __init__(self):
-            self.ava = {
-                provider_config["mxid_source_attribute"]: [source_attribute_value],
-            }
 
-            if display_name:
-                self.ava["displayName"] = [display_name]
+class FakeResponse:
+    def __init__(self, source_uid, display_name):
+        self.ava = {
+            "uid": [source_uid],
+        }
 
-    return FakeResponse()
+        if display_name:
+            self.ava["displayName"] = [display_name]
 
 
 class SamlUserAttributeTestCase(unittest.TestCase):
-
-    def _attribute_test(
-        self,
-        input_uid: str,
-        input_displayname: Optional[str],
-        output_localpart: str,
-        output_displayname: Optional[str],
-    ):
-        """Creates a dummy response, feeds it to the provider and checks the output
-
-        Args:
-            input_uid: The value of the mxid_source_attribute that the provider will
-                base the generated localpart off of.
-            input_displayname: The saml auth response displayName value that the
-                provider will generate a Matrix user displayname from.
-            output_localpart: The expected mxid localpart.
-            output_displayname: The expected matrix displayname.
-
+    def test_redirect(self):
+        """Creates a dummy response, feeds it to the provider and checks that it
+        redirects to the username picker.
         """
         provider, config = create_mapping_provider()
-        response = _make_test_saml_response(config, input_uid, input_displayname)
-
-        attribute_dict = provider.saml_response_to_user_attributes(response)
-        self.assertEqual(attribute_dict["mxid_localpart"], output_localpart)
-        self.assertEqual(attribute_dict["displayname"], output_displayname)
-
-    def test_normal_user(self):
-        self._attribute_test("john*doe2000#@example.com", None, "john.doe2000", None)
-
-    def test_normal_user_displayname(self):
-        self._attribute_test(
-            "john*doe2000#@example.com", "Jonny", "john.doe2000", "Jonny"
-        )
-
-    def test_multiple_adjacent_symbols(self):
-        self._attribute_test("bob%^$&#!bobby@example.com", None, "bob.bobby", None)
-
-    def test_username_does_not_end_with_dot(self):
-        """This is allowed in mxid syntax, but is not aesthetically pleasing"""
-        self._attribute_test("bob.bobby$@example.com", None, "bob.bobby", None)
-
-    def test_username_no_email(self):
-        self._attribute_test("bob.bobby", None, "bob.bobby", None)
-
-    def test_username_starting_with_underscore(self):
-        self._attribute_test(
-            "_twilight (sparkle)@somewhere.com", None, "twilight.sparkle", None
+        response = FakeResponse(123435, "Jonny")
+
+        # we expect this to redirect to the username picker
+        with self.assertRaises(RedirectException) as cm:
+            provider.saml_response_to_user_attributes(response, 0, "http://client/")
+        self.assertEqual(cm.exception.location, b"/_matrix/saml2/pick_username/")
+
+        cookieheader = cm.exception.cookies[0]
+        regex = re.compile(b"^username_mapping_session=([a-zA-Z]+);")
+        m = regex.search(cookieheader)
+        if not m:
+            self.fail("cookie header %s does not match %s" % (cookieheader, regex))
+
+        session_id = m.group(1).decode("ascii")
+        self.assertIn(
+            session_id, username_mapping_sessions, "session id not found in map"
         )
-
-    def test_existing_user(self):
-        provider, config = create_mapping_provider()
-        response = _make_test_saml_response(config, "wibble%@wobble.com", None)
-
-        attribute_dict = provider.saml_response_to_user_attributes(response)
-
-        # Simulate a failure on the first attempt
-        attribute_dict = provider.saml_response_to_user_attributes(response, failures=1)
-
-        self.assertEqual(attribute_dict["mxid_localpart"], "wibble1")
-        self.assertEqual(attribute_dict["displayname"], None)
+        session = username_mapping_sessions[session_id]
+        self.assertEqual(session.remote_user_id, 123435)
+        self.assertEqual(session.displayname, "Jonny")
+        self.assertEqual(session.client_redirect_url, "http://client/")
+
+        # the expiry time should be about 15 minutes away
+        expected_expiry = (time.time() + 15 * 60) * 1000
+        self.assertGreaterEqual(session.expiry_time_ms, expected_expiry - 1000)
+        self.assertLessEqual(session.expiry_time_ms, expected_expiry + 1000)
diff --git a/tox.ini b/tox.ini
index 262f7b3..6e1367a 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,25 +1,33 @@
 [tox]
-envlist = packaging, pep8
+envlist = packaging, lint, tests
 
 [testenv]
 setenv =
     PYTHONDONTWRITEBYTECODE = no_byte_code
-    PYTHONPATH = .
 
 [testenv:tests]
+deps =
+    git+git://github.com/matrix-org/synapse@rav/mozilla_username_hacks#egg=matrix-synapse
+
 commands =
     python -m unittest discover
 
 [testenv:packaging]
+skip_install = True
 deps =
     check-manifest
 commands =
     check-manifest
 
-[testenv:pep8]
+[testenv:lint]
 skip_install = True
 basepython = python3
 deps =
     flake8
+    # We pin so that our tests don't start failing on new releases of black.
+    black==19.10b0
+    isort
 commands =
-    flake8 saml_mapping_provider.py tests/*
+    python -m black --check --diff .
+    flake8 matrix_synapse_saml_mozilla tests
+    isort -c -df -sp setup.cfg -rc matrix_synapse_saml_mozilla tests