2023-12-05 13:16:01 +00:00
<!DOCTYPE HTML>
< html lang = "en" class = "sidebar-visible no-js light" >
< head >
<!-- Book generated using mdBook -->
< meta charset = "UTF-8" >
< title > OpenID Connect - Synapse< / title >
<!-- Custom HTML head -->
< meta content = "text/html; charset=utf-8" http-equiv = "Content-Type" >
< meta name = "description" content = "" >
< meta name = "viewport" content = "width=device-width, initial-scale=1" >
< meta name = "theme-color" content = "#ffffff" / >
< link rel = "icon" href = "favicon.svg" >
< link rel = "shortcut icon" href = "favicon.png" >
< link rel = "stylesheet" href = "css/variables.css" >
< link rel = "stylesheet" href = "css/general.css" >
< link rel = "stylesheet" href = "css/chrome.css" >
< link rel = "stylesheet" href = "css/print.css" media = "print" >
<!-- Fonts -->
< link rel = "stylesheet" href = "FontAwesome/css/font-awesome.css" >
< link rel = "stylesheet" href = "fonts/fonts.css" >
<!-- Highlight.js Stylesheets -->
< link rel = "stylesheet" href = "highlight.css" >
< link rel = "stylesheet" href = "tomorrow-night.css" >
< link rel = "stylesheet" href = "ayu-highlight.css" >
<!-- Custom theme stylesheets -->
< link rel = "stylesheet" href = "docs/website_files/table-of-contents.css" >
< link rel = "stylesheet" href = "docs/website_files/remove-nav-buttons.css" >
< link rel = "stylesheet" href = "docs/website_files/indent-section-headers.css" >
2023-12-11 14:53:00 +00:00
< link rel = "stylesheet" href = "docs/website_files/version-picker.css" >
2023-12-05 13:16:01 +00:00
< / head >
< body >
<!-- Provide site root to javascript -->
< script type = "text/javascript" >
var path_to_root = "";
var default_theme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "navy" : "light";
< / script >
<!-- Work around some values being stored in localStorage wrapped in quotes -->
< script type = "text/javascript" >
try {
var theme = localStorage.getItem('mdbook-theme');
var sidebar = localStorage.getItem('mdbook-sidebar');
if (theme.startsWith('"') & & theme.endsWith('"')) {
localStorage.setItem('mdbook-theme', theme.slice(1, theme.length - 1));
}
if (sidebar.startsWith('"') & & sidebar.endsWith('"')) {
localStorage.setItem('mdbook-sidebar', sidebar.slice(1, sidebar.length - 1));
}
} catch (e) { }
< / script >
<!-- Set the theme before any content is loaded, prevents flash -->
< script type = "text/javascript" >
var theme;
try { theme = localStorage.getItem('mdbook-theme'); } catch(e) { }
if (theme === null || theme === undefined) { theme = default_theme; }
var html = document.querySelector('html');
html.classList.remove('no-js')
html.classList.remove('light')
html.classList.add(theme);
html.classList.add('js');
< / script >
<!-- Hide / unhide sidebar before it is displayed -->
< script type = "text/javascript" >
var html = document.querySelector('html');
var sidebar = 'hidden';
if (document.body.clientWidth >= 1080) {
try { sidebar = localStorage.getItem('mdbook-sidebar'); } catch(e) { }
sidebar = sidebar || 'visible';
}
html.classList.remove('sidebar-visible');
html.classList.add("sidebar-" + sidebar);
< / script >
< nav id = "sidebar" class = "sidebar" aria-label = "Table of contents" >
< div class = "sidebar-scrollbox" >
< ol class = "chapter" > < li class = "chapter-item expanded affix " > < li class = "part-title" > Introduction< / li > < li class = "chapter-item expanded " > < a href = "welcome_and_overview.html" > Welcome and Overview< / a > < / li > < li class = "chapter-item expanded affix " > < li class = "part-title" > Setup< / li > < li class = "chapter-item expanded " > < a href = "setup/installation.html" > Installation< / a > < / li > < li class = "chapter-item expanded " > < a href = "postgres.html" > Using Postgres< / a > < / li > < li class = "chapter-item expanded " > < a href = "reverse_proxy.html" > Configuring a Reverse Proxy< / a > < / li > < li class = "chapter-item expanded " > < a href = "setup/forward_proxy.html" > Configuring a Forward/Outbound Proxy< / a > < / li > < li class = "chapter-item expanded " > < a href = "turn-howto.html" > Configuring a Turn Server< / a > < / li > < li > < ol class = "section" > < li class = "chapter-item expanded " > < a href = "setup/turn/coturn.html" > coturn TURN server< / a > < / li > < li class = "chapter-item expanded " > < a href = "setup/turn/eturnal.html" > eturnal TURN server< / a > < / li > < / ol > < / li > < li class = "chapter-item expanded " > < a href = "delegate.html" > Delegation< / a > < / li > < li class = "chapter-item expanded affix " > < li class = "part-title" > Upgrading< / li > < li class = "chapter-item expanded " > < a href = "upgrade.html" > Upgrading between Synapse Versions< / a > < / li > < li class = "chapter-item expanded affix " > < li class = "part-title" > Usage< / li > < li class = "chapter-item expanded " > < a href = "federate.html" > Federation< / a > < / li > < li class = "chapter-item expanded " > < a href = "usage/configuration/index.html" > Configuration< / a > < / li > < li > < ol class = "section" > < li class = "chapter-item expanded " > < a href = "usage/configuration/config_documentation.html" > Configuration Manual< / a > < / li > < li class = "chapter-item expanded " > < a href = "usage/configuration/homeserver_sample_config.html" > Homeserver Sample Config File< / a > < / li > < li class = "chapter-item expanded " > < a href = "usage/configuration/logging_sample_config.html" > Logging Sample Config File< / a > < / li > < li class = "chapter-item expanded " > < a href = "structured_logging.html" > Structured Logging< / a > < / li > < li class = "chapter-item expanded " > < a href = "templates.html" > Templates< / a > < / li > < li class = "chapter-item expanded " > < a href = "usage/configuration/user_authentication/index.html" > User Authentication< / a > < / li > < li > < ol class = "section" > < li class = "chapter-item expanded " > < a href = "usage/configuration/user_authentication/single_sign_on/index.html" > Single-Sign On< / a > < / li > < li > < ol class = "section" > < li class = "chapter-item expanded " > < a href = "openid.html" class = "active" > OpenID Connect< / a > < / li > < li class = "chapter-item expanded " > < a href = "usage/configuration/user_authentication/single_sign_on/saml.html" > SAML< / a > < / li > < li class = "chapter-item expanded " > < a href = "usage/configuration/user_authentication/single_sign_on/cas.html" > CAS< / a > < / li > < li class = "chapter-item expanded " > < a href = "sso_mapping_providers.html" > SSO Mapping Providers< / a > < / li > < / ol > < / li > < li class = "chapter-item expanded " > < a href = "password_auth_providers.html" > Password Auth Providers< / a > < / li > < li class = "chapter-item expanded " > < a href = "jwt.html" > JSON Web Tokens< / a > < / li > < li class = "chapter-item expanded " > < a href = "usage/configuration/user_authentication/refresh_tokens.html" > Refresh Tokens< / a > < / li > < / ol > < / li > < li class = "chapter-item expanded " > < a href = "CAPTCHA_SETUP.html" > Registration Captcha< / a > < / li > < li class = "chapter-item expanded " > < a href = "application_services.html" > Application Services< / a > < / li > < li class = "chapter-item expanded " > < a href = "server_notices.html" > Server Notices< / a > < / li > < li class = "chapter-item expanded " > < a href = "consent_tracking.html" > Consent Tracking< / a > < / li > < li class = "chapter-item expanded " > < a href = "user_directory.html" > User Directory< / a > < / li > < li class = "chapter-item expanded " > < a href = "message_retention_policies.html" > Message Retention Policies< / a > < / li > < li class = "chapter-item expanded " > < a href = "modules/index.html" > Pluggable Modules< / a > < / li > < li > < ol class = "section" > < li class = "chapter-item expanded " > < a href = "modules/writing_a_module.html" > Writing a module< / a > < / li > < li > < ol class = "section" > < li class = "chapter-item expanded " > < a href = "modules/spam_checker_callbacks.html" > Spam checker callbacks< / a > < / li > < li class
< / div >
< div id = "sidebar-resize-handle" class = "sidebar-resize-handle" > < / div >
< / nav >
< div id = "page-wrapper" class = "page-wrapper" >
< div class = "page" >
< div id = "menu-bar-hover-placeholder" > < / div >
< div id = "menu-bar" class = "menu-bar sticky bordered" >
< div class = "left-buttons" >
< button id = "sidebar-toggle" class = "icon-button" type = "button" title = "Toggle Table of Contents" aria-label = "Toggle Table of Contents" aria-controls = "sidebar" >
< i class = "fa fa-bars" > < / i >
< / button >
< button id = "theme-toggle" class = "icon-button" type = "button" title = "Change theme" aria-label = "Change theme" aria-haspopup = "true" aria-expanded = "false" aria-controls = "theme-list" >
< i class = "fa fa-paint-brush" > < / i >
< / button >
< ul id = "theme-list" class = "theme-popup" aria-label = "Themes" role = "menu" >
< li role = "none" > < button role = "menuitem" class = "theme" id = "light" > Light (default)< / button > < / li >
< li role = "none" > < button role = "menuitem" class = "theme" id = "rust" > Rust< / button > < / li >
< li role = "none" > < button role = "menuitem" class = "theme" id = "coal" > Coal< / button > < / li >
< li role = "none" > < button role = "menuitem" class = "theme" id = "navy" > Navy< / button > < / li >
< li role = "none" > < button role = "menuitem" class = "theme" id = "ayu" > Ayu< / button > < / li >
< / ul >
< button id = "search-toggle" class = "icon-button" type = "button" title = "Search. (Shortkey: s)" aria-label = "Toggle Searchbar" aria-expanded = "false" aria-keyshortcuts = "S" aria-controls = "searchbar" >
< i class = "fa fa-search" > < / i >
< / button >
2023-12-11 14:53:00 +00:00
< div class = "version-picker" >
< div class = "dropdown" >
< div class = "select" >
< span > < / span >
< i class = "fa fa-chevron-down" > < / i >
< / div >
< input type = "hidden" name = "version" >
< ul class = "dropdown-menu" >
<!-- Versions will be added dynamically in version - picker.js -->
< / ul >
< / div >
< / div >
2023-12-05 13:16:01 +00:00
< / div >
< h1 class = "menu-title" > Synapse< / h1 >
< div class = "right-buttons" >
< a href = "print.html" title = "Print this book" aria-label = "Print this book" >
< i id = "print-button" class = "fa fa-print" > < / i >
< / a >
< a href = "https://github.com/matrix-org/synapse" title = "Git repository" aria-label = "Git repository" >
< i id = "git-repository-button" class = "fa fa-github" > < / i >
< / a >
< a href = "https://github.com/matrix-org/synapse/edit/develop/docs/openid.md" title = "Suggest an edit" aria-label = "Suggest an edit" >
< i id = "git-edit-button" class = "fa fa-edit" > < / i >
< / a >
< / div >
< / div >
< div id = "search-wrapper" class = "hidden" >
< form id = "searchbar-outer" class = "searchbar-outer" >
< input type = "search" id = "searchbar" name = "searchbar" placeholder = "Search this book ..." aria-controls = "searchresults-outer" aria-describedby = "searchresults-header" >
< / form >
< div id = "searchresults-outer" class = "searchresults-outer hidden" >
< div id = "searchresults-header" class = "searchresults-header" > < / div >
< ul id = "searchresults" >
< / ul >
< / div >
< / div >
<!-- Apply ARIA attributes after the sidebar and the sidebar toggle button are added to the DOM -->
< script type = "text/javascript" >
document.getElementById('sidebar-toggle').setAttribute('aria-expanded', sidebar === 'visible');
document.getElementById('sidebar').setAttribute('aria-hidden', sidebar !== 'visible');
Array.from(document.querySelectorAll('#sidebar a')).forEach(function(link) {
link.setAttribute('tabIndex', sidebar === 'visible' ? 0 : -1);
});
< / script >
< div id = "content" class = "content" >
< main >
<!-- Page table of contents -->
< div class = "sidetoc" >
< nav class = "pagetoc" > < / nav >
< / div >
< h1 id = "configuring-synapse-to-authenticate-against-an-openid-connect-provider" > < a class = "header" href = "#configuring-synapse-to-authenticate-against-an-openid-connect-provider" > Configuring Synapse to authenticate against an OpenID Connect provider< / a > < / h1 >
< p > Synapse can be configured to use an OpenID Connect Provider (OP) for
authentication, instead of its own local password database.< / p >
< p > Any OP should work with Synapse, as long as it supports the authorization code
flow. There are a few options for that:< / p >
< ul >
< li >
< p > start a local OP. Synapse has been tested with < a href = "https://www.ory.sh/docs/hydra/" > Hydra< / a > and
< a href = "https://github.com/dexidp/dex" > Dex< / a > . Note that for an OP to work, it should be served under a
secure (HTTPS) origin. A certificate signed with a self-signed, locally
trusted CA should work. In that case, start Synapse with a < code > SSL_CERT_FILE< / code >
environment variable set to the path of the CA.< / p >
< / li >
< li >
< p > set up a SaaS OP, like < a href = "https://developers.google.com/identity/protocols/oauth2/openid-connect" > Google< / a > , < a href = "https://auth0.com/" > Auth0< / a > or
< a href = "https://www.okta.com/" > Okta< / a > . Synapse has been tested with Auth0 and Google.< / p >
< / li >
< / ul >
< p > It may also be possible to use other OAuth2 providers which provide the
< a href = "https://tools.ietf.org/html/rfc6749#section-4.1" > authorization code grant type< / a > ,
such as < a href = "https://developer.github.com/apps/building-oauth-apps/authorizing-oauth-apps" > Github< / a > .< / p >
< h2 id = "preparing-synapse" > < a class = "header" href = "#preparing-synapse" > Preparing Synapse< / a > < / h2 >
< p > The OpenID integration in Synapse uses the
< a href = "https://pypi.org/project/Authlib/" > < code > authlib< / code > < / a > library, which must be installed
as follows:< / p >
< ul >
< li >
< p > The relevant libraries are included in the Docker images and Debian packages
provided by < code > matrix.org< / code > so no further action is needed.< / p >
< / li >
< li >
< p > If you installed Synapse into a virtualenv, run < code > /path/to/env/bin/pip install matrix-synapse[oidc]< / code > to install the necessary dependencies.< / p >
< / li >
< li >
< p > For other installation mechanisms, see the documentation provided by the
maintainer.< / p >
< / li >
< / ul >
< p > To enable the OpenID integration, you should then add a section to the < code > oidc_providers< / code >
setting in your configuration file.
See the < a href = "usage/configuration/config_documentation.html#oidc_providers" > configuration manual< / a > for some sample settings, as well as
the text below for example configurations for specific providers.< / p >
< h2 id = "oidc-back-channel-logout" > < a class = "header" href = "#oidc-back-channel-logout" > OIDC Back-Channel Logout< / a > < / h2 >
< p > Synapse supports receiving < a href = "https://openid.net/specs/openid-connect-backchannel-1_0.html" > OpenID Connect Back-Channel Logout< / a > notifications.< / p >
< p > This lets the OpenID Connect Provider notify Synapse when a user logs out, so that Synapse can end that user session.
This feature can be enabled by setting the < code > backchannel_logout_enabled< / code > property to < code > true< / code > in the provider configuration, and setting the following URL as destination for Back-Channel Logout notifications in your OpenID Connect Provider: < code > [synapse public baseurl]/_synapse/client/oidc/backchannel_logout< / code > < / p >
< h2 id = "sample-configs" > < a class = "header" href = "#sample-configs" > Sample configs< / a > < / h2 >
< p > Here are a few configs for providers that should work with Synapse.< / p >
< h3 id = "microsoft-azure-active-directory" > < a class = "header" href = "#microsoft-azure-active-directory" > Microsoft Azure Active Directory< / a > < / h3 >
< p > Azure AD can act as an OpenID Connect Provider. Register a new application under
< em > App registrations< / em > in the Azure AD management console. The RedirectURI for your
application should point to your matrix server:
< code > [synapse public baseurl]/_synapse/client/oidc/callback< / code > < / p >
< p > Go to < em > Certificates & secrets< / em > and register a new client secret. Make note of your
Directory (tenant) ID as it will be used in the Azure links.
Edit your Synapse config file and change the < code > oidc_config< / code > section:< / p >
< pre > < code class = "language-yaml" > oidc_providers:
- idp_id: microsoft
idp_name: Microsoft
issuer: " https://login.microsoftonline.com/< tenant id> /v2.0"
client_id: " < client id> "
client_secret: " < client secret> "
scopes: [" openid" , " profile" ]
authorization_endpoint: " https://login.microsoftonline.com/< tenant id> /oauth2/v2.0/authorize"
token_endpoint: " https://login.microsoftonline.com/< tenant id> /oauth2/v2.0/token"
userinfo_endpoint: " https://graph.microsoft.com/oidc/userinfo"
user_mapping_provider:
config:
localpart_template: " {{ user.preferred_username.split('@')[0] }}"
display_name_template: " {{ user.name }}"
< / code > < / pre >
< h3 id = "apple" > < a class = "header" href = "#apple" > Apple< / a > < / h3 >
< p > Configuring " Sign in with Apple" (SiWA) requires an Apple Developer account.< / p >
< p > You will need to create a new " Services ID" for SiWA, and create and download a
private key with " SiWA" enabled.< / p >
< p > As well as the private key file, you will need:< / p >
< ul >
< li > Client ID: the " identifier" you gave the " Services ID" < / li >
< li > Team ID: a 10-character ID associated with your developer account.< / li >
< li > Key ID: the 10-character identifier for the key.< / li >
< / ul >
< p > < a href = "https://help.apple.com/developer-account/?lang=en#/dev77c875b7e" > Apple's developer documentation< / a >
has more information on setting up SiWA.< / p >
< p > The synapse config will look like this:< / p >
< pre > < code class = "language-yaml" > - idp_id: apple
idp_name: Apple
issuer: " https://appleid.apple.com"
client_id: " your-client-id" # Set to the " identifier" for your " ServicesID"
client_auth_method: " client_secret_post"
client_secret_jwt_key:
key_file: " /path/to/AuthKey_KEYIDCODE.p8" # point to your key file
jwt_header:
alg: ES256
kid: " KEYIDCODE" # Set to the 10-char Key ID
jwt_payload:
iss: TEAMIDCODE # Set to the 10-char Team ID
scopes: [" name" , " email" , " openid" ]
authorization_endpoint: https://appleid.apple.com/auth/authorize?response_mode=form_post
user_mapping_provider:
config:
email_template: " {{ user.email }}"
< / code > < / pre >
< h3 id = "auth0" > < a class = "header" href = "#auth0" > Auth0< / a > < / h3 >
< p > < a href = "https://auth0.com/" > Auth0< / a > is a hosted SaaS IdP solution.< / p >
< ol >
< li >
< p > Create a regular web application for Synapse< / p >
< / li >
< li >
< p > Set the Allowed Callback URLs to < code > [synapse public baseurl]/_synapse/client/oidc/callback< / code > < / p >
< / li >
< li >
< p > Add a rule with any name to add the < code > preferred_username< / code > claim.
(See https://auth0.com/docs/customize/rules/create-rules for more information on how to create rules.)< / p >
< details >
< summary > Code sample< / summary >
< pre > < code class = "language-js" > function addPersistenceAttribute(user, context, callback) {
user.user_metadata = user.user_metadata || {};
user.user_metadata.preferred_username = user.user_metadata.preferred_username || user.user_id;
context.idToken.preferred_username = user.user_metadata.preferred_username;
auth0.users.updateUserMetadata(user.user_id, user.user_metadata)
.then(function(){
callback(null, user, context);
})
.catch(function(err){
callback(err);
});
}
< / code > < / pre >
< / li >
< / ol >
< / details >
< p > Synapse config:< / p >
< pre > < code class = "language-yaml" > oidc_providers:
- idp_id: auth0
idp_name: Auth0
issuer: " https://your-tier.eu.auth0.com/" # TO BE FILLED
client_id: " your-client-id" # TO BE FILLED
client_secret: " your-client-secret" # TO BE FILLED
scopes: [" openid" , " profile" ]
user_mapping_provider:
config:
localpart_template: " {{ user.preferred_username }}"
display_name_template: " {{ user.name }}"
< / code > < / pre >
< h3 id = "authentik" > < a class = "header" href = "#authentik" > Authentik< / a > < / h3 >
< p > < a href = "https://goauthentik.io/" > Authentik< / a > is an open-source IdP solution.< / p >
< ol >
< li > Create a provider in Authentik, with type OAuth2/OpenID.< / li >
< li > The parameters are:< / li >
< / ol >
< ul >
< li > Client Type: Confidential< / li >
< li > JWT Algorithm: RS256< / li >
< li > Scopes: OpenID, Email and Profile< / li >
< li > RSA Key: Select any available key< / li >
< li > Redirect URIs: < code > [synapse public baseurl]/_synapse/client/oidc/callback< / code > < / li >
< / ul >
< ol start = "3" >
< li > Create an application for synapse in Authentik and link it to the provider.< / li >
< li > Note the slug of your application, Client ID and Client Secret.< / li >
< / ol >
< p > Note: RSA keys must be used for signing for Authentik, ECC keys do not work.< / p >
< p > Synapse config:< / p >
< pre > < code class = "language-yaml" > oidc_providers:
- idp_id: authentik
idp_name: authentik
discover: true
issuer: " https://your.authentik.example.org/application/o/your-app-slug/" # TO BE FILLED: domain and slug
client_id: " your client id" # TO BE FILLED
client_secret: " your client secret" # TO BE FILLED
scopes:
- " openid"
- " profile"
- " email"
user_mapping_provider:
config:
localpart_template: " {{ user.preferred_username }}"
display_name_template: " {{ user.preferred_username|capitalize }}" # TO BE FILLED: If your users have names in Authentik and you want those in Synapse, this should be replaced with user.name|capitalize.
< / code > < / pre >
< h3 id = "dex" > < a class = "header" href = "#dex" > Dex< / a > < / h3 >
< p > < a href = "https://github.com/dexidp/dex" > Dex< / a > is a simple, open-source OpenID Connect Provider.
Although it is designed to help building a full-blown provider with an
external database, it can be configured with static passwords in a config file.< / p >
< p > Follow the < a href = "https://dexidp.io/docs/getting-started/" > Getting Started guide< / a >
to install Dex.< / p >
< p > Edit < code > examples/config-dev.yaml< / code > config file from the Dex repo to add a client:< / p >
< pre > < code class = "language-yaml" > staticClients:
- id: synapse
secret: secret
redirectURIs:
- '[synapse public baseurl]/_synapse/client/oidc/callback'
name: 'Synapse'
< / code > < / pre >
< p > Run with < code > dex serve examples/config-dev.yaml< / code > .< / p >
< p > Synapse config:< / p >
< pre > < code class = "language-yaml" > oidc_providers:
- idp_id: dex
idp_name: " My Dex server"
skip_verification: true # This is needed as Dex is served on an insecure endpoint
issuer: " http://127.0.0.1:5556/dex"
client_id: " synapse"
client_secret: " secret"
scopes: [" openid" , " profile" ]
user_mapping_provider:
config:
localpart_template: " {{ user.name }}"
display_name_template: " {{ user.name|capitalize }}"
< / code > < / pre >
< h3 id = "django-oauth-toolkit" > < a class = "header" href = "#django-oauth-toolkit" > Django OAuth Toolkit< / a > < / h3 >
< p > < a href = "https://github.com/jazzband/django-oauth-toolkit" > django-oauth-toolkit< / a > is a
Django application providing out of the box all the endpoints, data and logic
needed to add OAuth2 capabilities to your Django projects. It supports
< a href = "https://django-oauth-toolkit.readthedocs.io/en/latest/oidc.html" > OpenID Connect too< / a > .< / p >
< p > Configuration on Django's side:< / p >
< ol >
< li > Add an application: < code > https://example.com/admin/oauth2_provider/application/add/< / code > and choose parameters like this:< / li >
< / ol >
< ul >
< li > < code > Redirect uris< / code > : < code > https://synapse.example.com/_synapse/client/oidc/callback< / code > < / li >
< li > < code > Client type< / code > : < code > Confidential< / code > < / li >
< li > < code > Authorization grant type< / code > : < code > Authorization code< / code > < / li >
< li > < code > Algorithm< / code > : < code > HMAC with SHA-2 256< / code > < / li >
< / ul >
< ol start = "2" >
< li >
< p > You can < a href = "https://django-oauth-toolkit.readthedocs.io/en/latest/oidc.html#customizing-the-oidc-responses" > customize the claims< / a > Django gives to synapse (optional):< / p >
< details >
< summary > Code sample< / summary >
< pre > < code class = "language-python" > class CustomOAuth2Validator(OAuth2Validator):
def get_additional_claims(self, request):
return {
" sub" : request.user.email,
" email" : request.user.email,
" first_name" : request.user.first_name,
" last_name" : request.user.last_name,
}
< / code > < / pre >
< / details >
< / li >
< / ol >
< p > Your synapse config is then:< / p >
< pre > < code class = "language-yaml" > oidc_providers:
- idp_id: django_example
idp_name: " Django Example"
issuer: " https://example.com/o/"
client_id: " your-client-id" # CHANGE ME
client_secret: " your-client-secret" # CHANGE ME
scopes: [" openid" ]
user_profile_method: " userinfo_endpoint" # needed because oauth-toolkit does not include user information in the authorization response
user_mapping_provider:
config:
localpart_template: " {{ user.email.split('@')[0] }}"
display_name_template: " {{ user.first_name }} {{ user.last_name }}"
email_template: " {{ user.email }}"
< / code > < / pre >
< h3 id = "facebook" > < a class = "header" href = "#facebook" > Facebook< / a > < / h3 >
< ol start = "0" >
< li > You will need a Facebook developer account. You can register for one
< a href = "https://developers.facebook.com/async/registration/" > here< / a > .< / li >
< li > On the < a href = "https://developers.facebook.com/apps/" > apps< / a > page of the developer
console, " Create App" , and choose " Build Connected Experiences" .< / li >
< li > Once the app is created, add " Facebook Login" and choose " Web" . You don't
need to go through the whole form here.< / li >
< li > In the left-hand menu, open " Products" /" Facebook Login" /" Settings" .
< ul >
< li > Add < code > [synapse public baseurl]/_synapse/client/oidc/callback< / code > as an OAuth Redirect
URL.< / li >
< / ul >
< / li >
< li > In the left-hand menu, open " Settings/Basic" . Here you can copy the " App ID"
and " App Secret" for use below.< / li >
< / ol >
< p > Synapse config:< / p >
< pre > < code class = "language-yaml" > - idp_id: facebook
idp_name: Facebook
idp_brand: " facebook" # optional: styling hint for clients
discover: false
issuer: " https://www.facebook.com"
client_id: " your-client-id" # TO BE FILLED
client_secret: " your-client-secret" # TO BE FILLED
scopes: [" openid" , " email" ]
authorization_endpoint: " https://facebook.com/dialog/oauth"
token_endpoint: " https://graph.facebook.com/v9.0/oauth/access_token"
jwks_uri: " https://www.facebook.com/.well-known/oauth/openid/jwks/"
user_mapping_provider:
config:
display_name_template: " {{ user.name }}"
email_template: " {{ user.email }}"
< / code > < / pre >
< p > Relevant documents:< / p >
< ul >
< li > < a href = "https://developers.facebook.com/docs/facebook-login/manually-build-a-login-flow" > Manually Build a Login Flow< / a > < / li >
< li > < a href = "https://developers.facebook.com/docs/graph-api/using-graph-api/" > Using Facebook's Graph API< / a > < / li >
< li > < a href = "https://developers.facebook.com/docs/graph-api/reference/user" > Reference to the User endpoint< / a > < / li >
< / ul >
< p > Facebook do have an < a href = "https://www.facebook.com/.well-known/openid-configuration" > OIDC discovery endpoint< / a > ,
but it has a < code > response_types_supported< / code > which excludes " code" (which we rely on, and
is even mentioned in their < a href = "https://developers.facebook.com/docs/facebook-login/manually-build-a-login-flow#login" > documentation< / a > ),
so we have to disable discovery and configure the URIs manually.< / p >
< h3 id = "github" > < a class = "header" href = "#github" > GitHub< / a > < / h3 >
< p > < a href = "https://developer.github.com/apps/building-oauth-apps/authorizing-oauth-apps" > GitHub< / a > is a bit special as it is not an OpenID Connect compliant provider, but
just a regular OAuth2 provider.< / p >
< p > The < a href = "https://developer.github.com/v3/users/#get-the-authenticated-user" > < code > /user< / code > API endpoint< / a >
can be used to retrieve information on the authenticated user. As the Synapse
login mechanism needs an attribute to uniquely identify users, and that endpoint
does not return a < code > sub< / code > property, an alternative < code > subject_claim< / code > has to be set.< / p >
< ol >
< li > Create a new OAuth application: < a href = "https://github.com/settings/applications/new" > https://github.com/settings/applications/new< / a > .< / li >
< li > Set the callback URL to < code > [synapse public baseurl]/_synapse/client/oidc/callback< / code > .< / li >
< / ol >
< p > Synapse config:< / p >
< pre > < code class = "language-yaml" > oidc_providers:
- idp_id: github
idp_name: Github
idp_brand: " github" # optional: styling hint for clients
discover: false
issuer: " https://github.com/"
client_id: " your-client-id" # TO BE FILLED
client_secret: " your-client-secret" # TO BE FILLED
authorization_endpoint: " https://github.com/login/oauth/authorize"
token_endpoint: " https://github.com/login/oauth/access_token"
userinfo_endpoint: " https://api.github.com/user"
scopes: [" read:user" ]
user_mapping_provider:
config:
subject_claim: " id"
localpart_template: " {{ user.login }}"
display_name_template: " {{ user.name }}"
< / code > < / pre >
< h3 id = "gitlab" > < a class = "header" href = "#gitlab" > GitLab< / a > < / h3 >
< ol >
< li > Create a < a href = "https://gitlab.com/profile/applications" > new application< / a > .< / li >
< li > Add the < code > read_user< / code > and < code > openid< / code > scopes.< / li >
< li > Add this Callback URL: < code > [synapse public baseurl]/_synapse/client/oidc/callback< / code > < / li >
< / ol >
< p > Synapse config:< / p >
< pre > < code class = "language-yaml" > oidc_providers:
- idp_id: gitlab
idp_name: Gitlab
idp_brand: " gitlab" # optional: styling hint for clients
issuer: " https://gitlab.com/"
client_id: " your-client-id" # TO BE FILLED
client_secret: " your-client-secret" # TO BE FILLED
client_auth_method: " client_secret_post"
scopes: [" openid" , " read_user" ]
user_profile_method: " userinfo_endpoint"
user_mapping_provider:
config:
localpart_template: '{{ user.nickname }}'
display_name_template: '{{ user.name }}'
< / code > < / pre >
< h3 id = "gitea" > < a class = "header" href = "#gitea" > Gitea< / a > < / h3 >
< p > Gitea is, like Github, not an OpenID provider, but just an OAuth2 provider.< / p >
< p > The < a href = "https://try.gitea.io/api/swagger#/user/userGetCurrent" > < code > /user< / code > API endpoint< / a >
can be used to retrieve information on the authenticated user. As the Synapse
login mechanism needs an attribute to uniquely identify users, and that endpoint
does not return a < code > sub< / code > property, an alternative < code > subject_claim< / code > has to be set.< / p >
< ol >
< li > Create a new application.< / li >
< li > Add this Callback URL: < code > [synapse public baseurl]/_synapse/client/oidc/callback< / code > < / li >
< / ol >
< p > Synapse config:< / p >
< pre > < code class = "language-yaml" > oidc_providers:
- idp_id: gitea
idp_name: Gitea
discover: false
issuer: " https://your-gitea.com/"
client_id: " your-client-id" # TO BE FILLED
client_secret: " your-client-secret" # TO BE FILLED
client_auth_method: client_secret_post
scopes: [] # Gitea doesn't support Scopes
authorization_endpoint: " https://your-gitea.com/login/oauth/authorize"
token_endpoint: " https://your-gitea.com/login/oauth/access_token"
userinfo_endpoint: " https://your-gitea.com/api/v1/user"
user_mapping_provider:
config:
subject_claim: " id"
localpart_template: " {{ user.login }}"
display_name_template: " {{ user.full_name }}"
< / code > < / pre >
< h3 id = "google" > < a class = "header" href = "#google" > Google< / a > < / h3 >
< p > < a href = "https://developers.google.com/identity/protocols/oauth2/openid-connect" > Google< / a > is an OpenID certified authentication and authorisation provider.< / p >
< ol >
< li > Set up a project in the Google API Console (see
< a href = "https://developers.google.com/identity/protocols/oauth2/openid-connect#appsetup" > documentation< / a > ).< / li >
< li > Add an " OAuth Client ID" for a Web Application under " Credentials" .< / li >
< li > Copy the Client ID and Client Secret, and add the following to your synapse config:
< pre > < code class = "language-yaml" > oidc_providers:
- idp_id: google
idp_name: Google
idp_brand: " google" # optional: styling hint for clients
issuer: " https://accounts.google.com/"
client_id: " your-client-id" # TO BE FILLED
client_secret: " your-client-secret" # TO BE FILLED
scopes: [" openid" , " profile" , " email" ] # email is optional, read below
user_mapping_provider:
config:
localpart_template: " {{ user.given_name|lower }}"
display_name_template: " {{ user.name }}"
email_template: " {{ user.email }}" # needs " email" in scopes above
< / code > < / pre >
< / li >
< li > Back in the Google console, add this Authorized redirect URI: < code > [synapse public baseurl]/_synapse/client/oidc/callback< / code > .< / li >
< / ol >
< h3 id = "keycloak" > < a class = "header" href = "#keycloak" > Keycloak< / a > < / h3 >
< p > < a href = "https://www.keycloak.org/docs/latest/server_admin/#sso-protocols" > Keycloak< / a > is an opensource IdP maintained by Red Hat.< / p >
< p > Keycloak supports OIDC Back-Channel Logout, which sends logout notification to Synapse, so that Synapse users get logged out when they log out from Keycloak.
This can be optionally enabled by setting < code > backchannel_logout_enabled< / code > to < code > true< / code > in the Synapse configuration, and by setting the " Backchannel Logout URL" in Keycloak.< / p >
< p > Follow the < a href = "https://www.keycloak.org/guides" > Getting Started Guide< / a > to install Keycloak and set up a realm.< / p >
< ol >
< li >
< p > Click < code > Clients< / code > in the sidebar and click < code > Create< / code > < / p >
< / li >
< li >
< p > Fill in the fields as below:< / p >
< / li >
< / ol >
< table > < thead > < tr > < th > Field< / th > < th > Value< / th > < / tr > < / thead > < tbody >
< tr > < td > Client ID< / td > < td > < code > synapse< / code > < / td > < / tr >
< tr > < td > Client Protocol< / td > < td > < code > openid-connect< / code > < / td > < / tr >
< / tbody > < / table >
< ol start = "3" >
< li > Click < code > Save< / code > < / li >
< li > Fill in the fields as below:< / li >
< / ol >
< table > < thead > < tr > < th > Field< / th > < th > Value< / th > < / tr > < / thead > < tbody >
< tr > < td > Client ID< / td > < td > < code > synapse< / code > < / td > < / tr >
< tr > < td > Enabled< / td > < td > < code > On< / code > < / td > < / tr >
< tr > < td > Client Protocol< / td > < td > < code > openid-connect< / code > < / td > < / tr >
< tr > < td > Access Type< / td > < td > < code > confidential< / code > < / td > < / tr >
< tr > < td > Valid Redirect URIs< / td > < td > < code > [synapse public baseurl]/_synapse/client/oidc/callback< / code > < / td > < / tr >
< tr > < td > Backchannel Logout URL (optional)< / td > < td > < code > [synapse public baseurl]/_synapse/client/oidc/backchannel_logout< / code > < / td > < / tr >
< tr > < td > Backchannel Logout Session Required (optional)< / td > < td > < code > On< / code > < / td > < / tr >
< / tbody > < / table >
< ol start = "5" >
< li > Click < code > Save< / code > < / li >
< li > On the Credentials tab, update the fields:< / li >
< / ol >
< table > < thead > < tr > < th > Field< / th > < th > Value< / th > < / tr > < / thead > < tbody >
< tr > < td > Client Authenticator< / td > < td > < code > Client ID and Secret< / code > < / td > < / tr >
< / tbody > < / table >
< ol start = "7" >
< li > Click < code > Regenerate Secret< / code > < / li >
< li > Copy Secret< / li >
< / ol >
< pre > < code class = "language-yaml" > oidc_providers:
- idp_id: keycloak
idp_name: " My KeyCloak server"
issuer: " https://127.0.0.1:8443/realms/{realm_name}"
client_id: " synapse"
client_secret: " copy secret generated from above"
scopes: [" openid" , " profile" ]
user_mapping_provider:
config:
localpart_template: " {{ user.preferred_username }}"
display_name_template: " {{ user.name }}"
backchannel_logout_enabled: true # Optional
< / code > < / pre >
< h3 id = "lemonldap" > < a class = "header" href = "#lemonldap" > LemonLDAP< / a > < / h3 >
< p > < a href = "https://lemonldap-ng.org/" > LemonLDAP::NG< / a > is an open-source IdP solution.< / p >
< ol >
< li > Create an OpenID Connect Relying Parties in LemonLDAP::NG< / li >
< li > The parameters are:< / li >
< / ol >
< ul >
< li > Client ID under the basic menu of the new Relying Parties (< code > Options > Basic > Client ID< / code > )< / li >
< li > Client secret (< code > Options > Basic > Client secret< / code > )< / li >
< li > JWT Algorithm: RS256 within the security menu of the new Relying Parties
(< code > Options > Security > ID Token signature algorithm< / code > and < code > Options > Security > Access Token signature algorithm< / code > )< / li >
< li > Scopes: OpenID, Email and Profile< / li >
< li > Allowed redirection addresses for login (< code > Options > Basic > Allowed redirection addresses for login< / code > ) :
< code > [synapse public baseurl]/_synapse/client/oidc/callback< / code > < / li >
< / ul >
< p > Synapse config:< / p >
< pre > < code class = "language-yaml" > oidc_providers:
- idp_id: lemonldap
idp_name: lemonldap
discover: true
issuer: " https://auth.example.org/" # TO BE FILLED: replace with your domain
client_id: " your client id" # TO BE FILLED
client_secret: " your client secret" # TO BE FILLED
scopes:
- " openid"
- " profile"
- " email"
user_mapping_provider:
config:
localpart_template: " {{ user.preferred_username }}}"
# TO BE FILLED: If your users have names in LemonLDAP::NG and you want those in Synapse, this should be replaced with user.name|capitalize or any valid filter.
display_name_template: " {{ user.preferred_username|capitalize }}"
< / code > < / pre >
< h3 id = "mastodon" > < a class = "header" href = "#mastodon" > Mastodon< / a > < / h3 >
< p > < a href = "https://docs.joinmastodon.org/" > Mastodon< / a > instances provide an < a href = "https://docs.joinmastodon.org/spec/oauth/" > OAuth API< / a > , allowing those instances to be used as a single sign-on provider for Synapse.< / p >
< p > The first step is to register Synapse as an application with your Mastodon instance, using the < a href = "https://docs.joinmastodon.org/methods/apps/#create" > Create an application API< / a > (see also < a href = "https://docs.joinmastodon.org/client/token/" > here< / a > ). There are several ways to do this, but in the example below we are using CURL.< / p >
< p > This example assumes that:< / p >
< ul >
< li > the Mastodon instance website URL is < code > https://your.mastodon.instance.url< / code > , and< / li >
< li > Synapse will be registered as an app named < code > my_synapse_app< / code > .< / li >
< / ul >
< p > Send the following request, substituting the value of < code > synapse_public_baseurl< / code > from your Synapse installation.< / p >
< pre > < code class = "language-sh" > curl -d " client_name=my_synapse_app& redirect_uris=https://[synapse_public_baseurl]/_synapse/client/oidc/callback" -X POST https://your.mastodon.instance.url/api/v1/apps
< / code > < / pre >
< p > You should receive a response similar to the following. Make sure to save it.< / p >
< pre > < code class = "language-json" > {" client_id" :" someclientid_123" ," client_secret" :" someclientsecret_123" ," id" :" 12345" ," name" :" my_synapse_app" ," redirect_uri" :" https://[synapse_public_baseurl]/_synapse/client/oidc/callback" ," website" :null," vapid_key" :" somerandomvapidkey_123" }
< / code > < / pre >
< p > As the Synapse login mechanism needs an attribute to uniquely identify users, and Mastodon's endpoint does not return a < code > sub< / code > property, an alternative < code > subject_template< / code > has to be set. Your Synapse configuration should include the following:< / p >
< pre > < code class = "language-yaml" > oidc_providers:
- idp_id: my_mastodon
idp_name: " Mastodon Instance Example"
discover: false
issuer: " https://your.mastodon.instance.url/@admin"
client_id: " someclientid_123"
client_secret: " someclientsecret_123"
authorization_endpoint: " https://your.mastodon.instance.url/oauth/authorize"
token_endpoint: " https://your.mastodon.instance.url/oauth/token"
userinfo_endpoint: " https://your.mastodon.instance.url/api/v1/accounts/verify_credentials"
scopes: [" read" ]
user_mapping_provider:
config:
subject_template: " {{ user.id }}"
localpart_template: " {{ user.username }}"
display_name_template: " {{ user.display_name }}"
< / code > < / pre >
< p > Note that the fields < code > client_id< / code > and < code > client_secret< / code > are taken from the CURL response above.< / p >
< h3 id = "shibboleth-with-oidc-plugin" > < a class = "header" href = "#shibboleth-with-oidc-plugin" > Shibboleth with OIDC Plugin< / a > < / h3 >
< p > < a href = "https://www.shibboleth.net/" > Shibboleth< / a > is an open Standard IdP solution widely used by Universities.< / p >
< ol >
< li > Shibboleth needs the < a href = "https://shibboleth.atlassian.net/wiki/spaces/IDPPLUGINS/pages/1376878976/OIDC+OP" > OIDC Plugin< / a > installed and working correctly.< / li >
< li > Create a new config on the IdP Side, ensure that the < code > client_id< / code > and < code > client_secret< / code >
are randomly generated data.< / li >
< / ol >
< pre > < code class = "language-json" > {
" client_id" : " SOME-CLIENT-ID" ,
" client_secret" : " SOME-SUPER-SECRET-SECRET" ,
" response_types" : [" code" ],
" grant_types" : [" authorization_code" ],
" scope" : " openid profile email" ,
" redirect_uris" : [" https://[synapse public baseurl]/_synapse/client/oidc/callback" ]
}
< / code > < / pre >
< p > Synapse config:< / p >
< pre > < code class = "language-yaml" > oidc_providers:
# Shibboleth IDP
#
- idp_id: shibboleth
idp_name: " Shibboleth Login"
discover: true
issuer: " https://YOUR-IDP-URL.TLD"
client_id: " YOUR_CLIENT_ID"
client_secret: " YOUR-CLIENT-SECRECT-FROM-YOUR-IDP"
scopes: [" openid" , " profile" , " email" ]
allow_existing_users: true
user_profile_method: " userinfo_endpoint"
user_mapping_provider:
config:
subject_claim: " sub"
localpart_template: " {{ user.sub.split('@')[0] }}"
display_name_template: " {{ user.name }}"
email_template: " {{ user.email }}"
< / code > < / pre >
< h3 id = "twitch" > < a class = "header" href = "#twitch" > Twitch< / a > < / h3 >
< ol >
< li > Setup a developer account on < a href = "https://dev.twitch.tv/" > Twitch< / a > < / li >
< li > Obtain the OAuth 2.0 credentials by < a href = "https://dev.twitch.tv/console/apps/" > creating an app< / a > < / li >
< li > Add this OAuth Redirect URL: < code > [synapse public baseurl]/_synapse/client/oidc/callback< / code > < / li >
< / ol >
< p > Synapse config:< / p >
< pre > < code class = "language-yaml" > oidc_providers:
- idp_id: twitch
idp_name: Twitch
issuer: " https://id.twitch.tv/oauth2/"
client_id: " your-client-id" # TO BE FILLED
client_secret: " your-client-secret" # TO BE FILLED
client_auth_method: " client_secret_post"
user_mapping_provider:
config:
localpart_template: " {{ user.preferred_username }}"
display_name_template: " {{ user.name }}"
< / code > < / pre >
< h3 id = "twitter" > < a class = "header" href = "#twitter" > Twitter< / a > < / h3 >
< p > < em > Using Twitter as an identity provider requires using Synapse 1.75.0 or later.< / em > < / p >
< ol >
< li > Setup a developer account on < a href = "https://developer.twitter.com/en/portal/dashboard" > Twitter< / a > < / li >
< li > Create a project & app.< / li >
< li > Enable user authentication and under " Type of App" choose " Web App, Automated App or Bot" .< / li >
< li > Under " App info" set the callback URL to < code > [synapse public baseurl]/_synapse/client/oidc/callback< / code > .< / li >
< li > Obtain the OAuth 2.0 credentials under the " Keys and tokens" tab, copy the " OAuth 2.0 Client ID and Client Secret" < / li >
< / ol >
< p > Synapse config:< / p >
< pre > < code class = "language-yaml" > oidc_providers:
- idp_id: twitter
idp_name: Twitter
idp_brand: " twitter" # optional: styling hint for clients
discover: false # Twitter is not OpenID compliant.
issuer: " https://twitter.com/"
client_id: " your-client-id" # TO BE FILLED
client_secret: " your-client-secret" # TO BE FILLED
pkce_method: " always"
# offline.access providers refresh tokens, tweet.read and users.read needed for userinfo request.
scopes: [" offline.access" , " tweet.read" , " users.read" ]
authorization_endpoint: https://twitter.com/i/oauth2/authorize
token_endpoint: https://api.twitter.com/2/oauth2/token
userinfo_endpoint: https://api.twitter.com/2/users/me?user.fields=profile_image_url
user_mapping_provider:
config:
subject_template: " {{ user.data.id }}"
localpart_template: " {{ user.data.username }}"
display_name_template: " {{ user.data.name }}"
picture_template: " {{ user.data.profile_image_url }}"
< / code > < / pre >
< h3 id = "xwiki" > < a class = "header" href = "#xwiki" > XWiki< / a > < / h3 >
< p > Install < a href = "https://extensions.xwiki.org/xwiki/bin/view/Extension/OpenID%20Connect/OpenID%20Connect%20Provider/" > OpenID Connect Provider< / a > extension in your < a href = "https://www.xwiki.org" > XWiki< / a > instance.< / p >
< p > Synapse config:< / p >
< pre > < code class = "language-yaml" > oidc_providers:
- idp_id: xwiki
idp_name: " XWiki"
issuer: " https://myxwikihost/xwiki/oidc/"
client_id: " your-client-id" # TO BE FILLED
client_auth_method: none
scopes: [" openid" , " profile" ]
user_profile_method: " userinfo_endpoint"
user_mapping_provider:
config:
localpart_template: " {{ user.preferred_username }}"
display_name_template: " {{ user.name }}"
< / code > < / pre >
< / main >
< nav class = "nav-wrapper" aria-label = "Page navigation" >
<!-- Mobile navigation buttons -->
< a rel = "prev" href = "usage/configuration/user_authentication/single_sign_on/index.html" class = "mobile-nav-chapters previous" title = "Previous chapter" aria-label = "Previous chapter" aria-keyshortcuts = "Left" >
< i class = "fa fa-angle-left" > < / i >
< / a >
< a rel = "next" href = "usage/configuration/user_authentication/single_sign_on/saml.html" class = "mobile-nav-chapters next" title = "Next chapter" aria-label = "Next chapter" aria-keyshortcuts = "Right" >
< i class = "fa fa-angle-right" > < / i >
< / a >
< div style = "clear: both" > < / div >
< / nav >
< / div >
< / div >
< nav class = "nav-wide-wrapper" aria-label = "Page navigation" >
< a rel = "prev" href = "usage/configuration/user_authentication/single_sign_on/index.html" class = "nav-chapters previous" title = "Previous chapter" aria-label = "Previous chapter" aria-keyshortcuts = "Left" >
< i class = "fa fa-angle-left" > < / i >
< / a >
< a rel = "next" href = "usage/configuration/user_authentication/single_sign_on/saml.html" class = "nav-chapters next" title = "Next chapter" aria-label = "Next chapter" aria-keyshortcuts = "Right" >
< i class = "fa fa-angle-right" > < / i >
< / a >
< / nav >
< / div >
< script type = "text/javascript" >
window.playground_copyable = true;
< / script >
< script src = "elasticlunr.min.js" type = "text/javascript" charset = "utf-8" > < / script >
< script src = "mark.min.js" type = "text/javascript" charset = "utf-8" > < / script >
< script src = "searcher.js" type = "text/javascript" charset = "utf-8" > < / script >
< script src = "clipboard.min.js" type = "text/javascript" charset = "utf-8" > < / script >
< script src = "highlight.js" type = "text/javascript" charset = "utf-8" > < / script >
< script src = "book.js" type = "text/javascript" charset = "utf-8" > < / script >
<!-- Custom JS scripts -->
< script type = "text/javascript" src = "docs/website_files/table-of-contents.js" > < / script >
2023-12-11 14:53:00 +00:00
< script type = "text/javascript" src = "docs/website_files/version-picker.js" > < / script >
< script type = "text/javascript" src = "docs/website_files/version.js" > < / script >
2023-12-05 13:16:01 +00:00
< / body >
2023-12-11 14:53:00 +00:00
< / html >