diff --git a/bin/podman_shared b/bin/podman_shared
index 4da85b6f..3362e252 100644
--- a/bin/podman_shared
+++ b/bin/podman_shared
@@ -1,324 +1,327 @@
 #!/bin/bash
 
 PODMAN=podman
 
 
 podman__build() {
     path=$1
     shift
     name=$1
     shift
     if [[ "$CACHE_REGISTRY" != "" ]]; then
         CACHE_ARGS="--layers --cache-from=$CACHE_REGISTRY/$name --cache-to=$CACHE_REGISTRY/$name --cache-ttl=24h"
     fi
     podman build $@ $CACHE_ARGS $path -t $name
 }
 
 podman__build_base() {
     podman__build docker/base/ apheleia/almalinux9 -f almalinux9
     podman__build docker/swoole apheleia/swoole
 }
 
 podman__build_webapp() {
     podman__build docker/webapp kolab-webapp --ulimit nofile=65535:65535 \
         ${KOLAB_GIT_REMOTE:+"--build-arg=GIT_REMOTE=$KOLAB_GIT_REMOTE"} \
         ${KOLAB_GIT_REF:+"--build-arg=GIT_REF=$KOLAB_GIT_REF"}
 }
 
 podman__build_meet() {
     podman__build docker/meet kolab-meet --ulimit nofile=65535:65535 \
         ${KOLAB_GIT_REMOTE:+"--build-arg=GIT_REMOTE=$KOLAB_GIT_REMOTE"} \
         ${KOLAB_GIT_REF:+"--build-arg=GIT_REF=$KOLAB_GIT_REF"}
 }
 
 podman__build_roundcube() {
     podman__build docker/roundcube roundcube --ulimit nofile=65535:65535 \
         ${GIT_REMOTE_ROUNDCUBEMAIL:+"--build-arg=GIT_REMOTE_ROUNDCUBEMAIL=$GIT_REMOTE_ROUNDCUBEMAIL"} \
         ${GIT_REF_ROUNDCUBEMAIL:+"--build-arg=GIT_REF_ROUNDCUBEMAIL=$GIT_REF_ROUNDCUBEMAIL"} \
         ${GIT_REMOTE_ROUNDCUBEMAIL_PLUGINS:+"--build-arg=GIT_REMOTE_ROUNDCUBEMAIL_PLUGINS=$GIT_REMOTE_ROUNDCUBEMAIL_PLUGINS"} \
         ${GIT_REF_ROUNDCUBEMAIL_PLUGINS:+"--build-arg=GIT_REF_ROUNDCUBEMAIL_PLUGINS=$GIT_REF_ROUNDCUBEMAIL_PLUGINS"} \
         ${GIT_REMOTE_CHWALA:+"--build-arg=GIT_REMOTE_CHWALA=$GIT_REMOTE_CHWALA"} \
         ${GIT_REF_CHWALA:+"--build-arg=GIT_REF_CHWALA=$GIT_REF_CHWALA"} \
         ${GIT_REMOTE_SYNCROTON:+"--build-arg=GIT_REMOTE_SYNCROTON=$GIT_REMOTE_SYNCROTON"} \
         ${GIT_REF_SYNCROTON:+"--build-arg=GIT_REF_SYNCROTON=$GIT_REF_SYNCROTON"} \
         ${GIT_REMOTE_AUTOCONF:+"--build-arg=GIT_REMOTE_AUTOCONF=$GIT_REMOTE_AUTOCONF"} \
         ${GIT_REF_AUTOCONF:+"--build-arg=GIT_REF_AUTOCONF=$GIT_REF_AUTOCONF"} \
         ${GIT_REMOTE_IRONY:+"--build-arg=GIT_REMOTE_IRONY=$GIT_REMOTE_IRONY"} \
         ${GIT_REF_IRONY:+"--build-arg=GIT_REF_IRONY=$GIT_REF_IRONY"} \
         ${GIT_REMOTE_FREEBUSY:+"--build-arg=GIT_REMOTE_FREEBUSY=$GIT_REMOTE_FREEBUSY"} \
         ${GIT_REF_FREEBUSY:+"--build-arg=GIT_REF_FREEBUSY=$GIT_REF_FREEBUSY"}
 }
 
 podman__build_postfix() {
     podman__build docker/postfix kolab-postfix
 }
 
 podman__build_imap() {
     podman__build docker/imap kolab-imap \
         ${IMAP_GIT_REMOTE:+"--build-arg=GIT_REMOTE=$IMAP_GIT_REMOTE"} \
         ${IMAP_GIT_REF:+"--build-arg=GIT_REF=$IMAP_GIT_REF"}
 }
 
 podman__build_amavis() {
     podman__build docker/amavis kolab-amavis
 }
 
 podman__build_proxy() {
     podman__build docker/proxy kolab-proxy
 }
 
 podman__build_collabora() {
     podman build docker/collabora -t kolab-collabora --build-arg=REPOSITORY="https://www.collaboraoffice.com/repos/CollaboraOnline/23.05-CODE/CODE-rpm/"
 }
 
 podman__build_coturn() {
     podman build docker/coturn -t kolab-coturn
 }
 
 podman__build_utils() {
     podman build docker/utils -t kolab-utils
 }
 
 podman__build_all() {
     podman__build_base
     podman__build_webapp
     podman__build_meet
     podman__build_postfix
     podman__build_imap
     podman__build_amavis
     podman__build_collabora
     podman build docker/mariadb -t mariadb
     podman build docker/redis -t redis
     podman__build_proxy
     podman__build_coturn
     podman__build_utils
     podman build docker/fluentbit -t fluentbit
 
     podman build docker/synapse -t synapse
     podman build docker/element -t element
     podman__build_roundcube
 }
 
 kolab__validate() {
     POD=$1
     $PODMAN exec $POD-imap testsaslauthd -u cyrus-admin -p simple123
     $PODMAN exec $POD-imap testsaslauthd -u "john@kolab.org" -p simple123
     # Ensure the inbox is created
     FOUND=false
     for i in {1..60}; do
         if $PODMAN exec $POD-imap bash -c 'echo "lm" | cyradm --auth PLAIN -u cyrus-admin -w simple123 --port 11143 localhost | grep "user/john@kolab.org"'; then
             echo "Found mailbox";
             FOUND=true
             break
         else
             echo "Waiting for mailbox";
             sleep 1;
         fi
     done
     if ! $FOUND; then
         echo "Failed to find the inbox for john@kolab.org"
         exit 1
     fi
 }
 
 podman__is_ready() {
     if [[ "$(timeout 5 podman wait --condition running $1)" != "-1" ]]; then
         echo "Container $1 is not running"
         return 1
     fi
     # We can only wait for healthy if healthcheck is available
     return 0
 }
 
 podman__healthcheck() {
     for CONTAINER in $@; do
         echo "Waiting for ${CONTAINER} become healthy "
         while [ $(podman healthcheck run ${CONTAINER}) ]; do
             echo -n "."; sleep 5;
         done
     done
 }
 
 podman__run_proxy() {
     $PODMAN run -dt --pod $POD --name $POD-proxy --replace \
         -v $CERTS_PATH:/etc/certs:ro \
         -v /etc/letsencrypt/:/etc/letsencrypt/:ro \
         -e APP_WEBSITE_DOMAIN \
         -e SSL_CERTIFICATE=${KOLAB_SSL_CERTIFICATE} \
         -e SSL_CERTIFICATE_KEY=${KOLAB_SSL_CERTIFICATE_KEY} \
         -e WEBAPP_BACKEND="http://localhost:8000" \
         -e MEET_BACKEND="http://localhost:12080" \
         -e ROUNDCUBE_BACKEND="http://localhost:8080" \
         -e DAV_BACKEND="http://localhost:11080/dav" \
         -e COLLABORA_BACKEND="http://localhost:9980" \
         -e MATRIX_BACKEND="http://localhost:8008" \
         -e ELEMENT_BACKEND="http://localhost:8880" \
         -e SIEVE_BACKEND="localhost:4190" \
         kolab-proxy:latest
 }
 
 podman__run_roundcube() {
     $PODMAN run -dt --pod $POD --name $POD-roundcube --replace \
         -v ./ext:/src.orig:ro \
         -e APP_DOMAIN \
         -e DES_KEY \
         -e DB_HOST \
         -e DB_RC_DATABASE="roundcube" \
         -e DB_RC_USERNAME="roundcube" \
         -e DB_RC_PASSWORD="${DB_PASSWORD:?"missing env variable"}" \
         -e IMAP_HOST=127.0.0.1 \
         -e IMAP_PORT=11143 \
         -e IMAP_ADMIN_LOGIN \
         -e IMAP_ADMIN_PASSWORD \
         -e SUBMISSION_HOST=127.0.0.1 \
         -e SUBMISSION_ENCRYPTION=starttls \
         -e SUBMISSION_PORT=10587 \
         -e IMAP_DEBUG \
         -e LOG_DRIVER=stdout \
         -e KOLAB_FILES_SERVER_URL=http://localhost:8080/chwala \
         -e FILEAPI_WOPI_OFFICE=http://localhost:9980 \
         -e FILEAPI_KOLABFILES_BASEURI=http://localhost:8000/api \
         -e FILE_API_SERVER_URL=http://localhost:8080/chwala/api/ \
         -e KOLAB_ADDRESSBOOK_CARDDAV_SERVER=http://localhost:11080/dav \
         -e CALENDAR_CALDAV_SERVER=http://localhost:11080/dav \
         -e TASKLIST_CALDAV_SERVER=http://localhost:11080/dav \
         roundcube:latest
 }
 
 podman__run_mariadb() {
     $PODMAN run -dt --pod $POD --name $POD-mariadb --replace \
          $MARIADB_STORAGE \
         -e MYSQL_ROOT_PASSWORD=${DB_ROOT_PASSWORD:?"missing env variable"} \
         -e TZ="+02:00" \
         -e DB_HKCCP_DATABASE="kolabdev" \
         -e DB_HKCCP_USERNAME="kolabdev" \
         -e DB_HKCCP_PASSWORD=${DB_PASSWORD:?"missing env variable"} \
         -e DB_KOLAB_DATABASE="kolab" \
         -e DB_KOLAB_USERNAME="kolab" \
         -e DB_KOLAB_PASSWORD=${DB_PASSWORD:?"missing env variable"} \
         -e DB_RC_DATABASE="roundcube" \
         -e DB_RC_USERNAME="roundcube" \
         -e DB_RC_PASSWORD=${DB_PASSWORD:?"missing env variable"} \
         --health-cmd "mysqladmin -u root ping && test -e /tmp/initialized" \
         mariadb:latest
 }
 
 podman__run_redis() {
     $PODMAN run -dt --pod $POD --name $POD-redis --replace \
         $REDIS_STORAGE \
         --health-cmd "redis-cli ping || exit 1" \
         redis:latest
 }
 
 podman__run_minio() {
     $PODMAN run -dt --pod $POD --name $POD-minio --replace \
         $MINIO_STORAGE \
         -e MINIO_ROOT_USER=${MINIO_USER:?"missing env variable"} \
         -e MINIO_ROOT_PASSWORD=${MINIO_PASSWORD:?"missing env variable"} \
         --health-cmd "mc ready local || exit 1" \
         --entrypoint sh \
         quay.io/minio/minio:latest -c 'mkdir -p /data/kolab && minio server /data --console-address ":9001"'
 }
 
 podman__run_webapp() {
     # We run with a fixed config.demo overlay and override the environment with ci/env
     $PODMAN run -dt --pod $POD --name $POD-webapp --replace \
         --env-file=$1 \
         -v ./src:/src/kolabsrc.orig:ro \
         -v ./$2/src:/src/overlay:ro \
         -e NOENVFILE=true \
         -e APP_SERVICES_ALLOWED_DOMAINS="webapp,localhost,services.$HOST" \
         -e KOLAB_ROLE=combined \
         -e PASSPORT_PRIVATE_KEY="$PASSPORT_PRIVATE_KEY" \
         -e PASSPORT_PUBLIC_KEY="$PASSPORT_PUBLIC_KEY" \
         -e MINIO_ENDPOINT="http://localhost:9000" \
         -e MEET_SERVER_URLS="http://127.0.0.1:12080/meetmedia/api/" \
         -e MEET_SERVER_VERIFY_TLS=false \
         --health-cmd "./artisan octane:status || exit 1" \
         kolab-webapp:latest
 }
 
 podman__run_imap() {
     $PODMAN run -dt --pod $POD --name $POD-imap --replace \
         $IMAP_SPOOL_STORAGE \
         $IMAP_LIB_STORAGE \
         -e APP_SERVICES_DOMAIN="localhost" \
         -e SERVICES_PORT=8000 \
         -e IMAP_ADMIN_LOGIN \
         -e IMAP_ADMIN_PASSWORD \
         --health-cmd "test -e /run/saslauthd/mux && kill -0 \$(cat /var/run/master.pid)" \
         kolab-imap:latest
 }
 
 podman__run_postfix() {
     $PODMAN run -dt --pod $POD --name $POD-postfix --replace \
         --privileged \
         $POSTFIX_SPOOL_STORAGE \
         $POSTFIX_LIB_STORAGE \
         -v $CERTS_PATH:/etc/certs:ro \
         -v /etc/letsencrypt/:/etc/letsencrypt/:ro \
         -e SSL_CERTIFICATE="$KOLAB_SSL_CERTIFICATE" \
         -e SSL_CERTIFICATE_FULLCHAIN="$KOLAB_SSL_CERTIFICATE_FULLCHAIN" \
         -e SSL_CERTIFICATE_KEY="$KOLAB_SSL_CERTIFICATE_KEY" \
         -e APP_DOMAIN \
         -e APP_SERVICES_DOMAIN="localhost" \
         -e SERVICES_PORT=8000 \
         -e AMAVIS_HOST=127.0.0.1 \
         -e DB_HOST=127.0.0.1 \
         -e DB_USERNAME \
         -e DB_PASSWORD \
         -e DB_DATABASE \
         -e LMTP_DESTINATION="localhost:11024" \
         --health-cmd "test -e /run/saslauthd/mux && kill -0 \$(cat /var/spool/postfix/pid/master.pid)" \
         kolab-postfix:latest
 }
 
 podman__run_amavis() {
     $PODMAN run -dt --pod $POD --name $POD-amavis --replace \
         -e APP_DOMAIN \
         -e POSTFIX_HOST=localhost \
         -e DB_HOST=localhost \
         -e DB_USERNAME \
         -e DB_PASSWORD \
         -e DB_DATABASE \
         kolab-amavis:latest
 }
 
 podman__run_collabora() {
     $PODMAN run -dt --pod $POD --name $POD-collabora --replace \
         --privileged \
         -e ALLOWED_HOSTS=${APP_DOMAIN} \
         kolab-collabora:latest
 }
 
 podman__run_synapse() {
     $PODMAN run -dt --pod $POD --name $POD-synapse --replace \
-         $SYNAPSE_STORAGE \
+        $SYNAPSE_STORAGE \
+        -v $CERTS_PATH:/etc/certs:ro \
         -e APP_DOMAIN \
         -e KOLAB_URL="http://127.0.0.1:8000" \
+        -e SYNAPSE_OAUTH_CLIENT_ID="${PASSPORT_SYNAPSE_OAUTH_CLIENT_ID:?"missing env variable"}" \
+        -e SYNAPSE_OAUTH_CLIENT_SECRET="${PASSPORT_SYNAPSE_OAUTH_CLIENT_SECRET:?"missing env variable"}" \
         synapse:latest
 }
 
 podman__run_element() {
     $PODMAN run -dt --pod $POD --name $POD-element --replace \
         -e APP_DOMAIN \
         element:latest
 }
 
 podman__run_meet() {
     $PODMAN run -dt --pod $POD --name $POD-meet --replace \
         -v ./meet/server:/src/meet:ro \
         -e WEBRTC_LISTEN_IP=0.0.0.0 \
         -e WEBRTC_ANNOUNCED_ADDRESS=${PUBLIC_IP:?"missing env variable"} \
         -e PUBLIC_DOMAIN=$APP_DOMAIN \
         -e LISTENING_HOST=127.0.0.1 \
         -e LISTENING_PORT=12080 \
         -e DEBUG="*" \
         -e TURN_SERVER=none \
         -e AUTH_TOKEN=${MEET_SERVER_TOKEN} \
         -e WEBHOOK_TOKEN=${MEET_WEBHOOK_TOKEN} \
         -e WEBHOOK_URL=$APP_DOMAIN/api/webhooks/meet \
         -e SSL_CERT=none \
         -e FORCE_WSS=true \
         kolab-meet:latest
 }
diff --git a/ci/env b/ci/env
index e8d5530e..ddb34895 100644
--- a/ci/env
+++ b/ci/env
@@ -1,174 +1,176 @@
 APP_NAME=Kolab
 APP_ENV=local
 APP_KEY=
 APP_DEBUG=true
 APP_URL=https://kolab.local
 APP_PUBLIC_URL=https://kolab.local
 APP_DOMAIN=kolab.local
 APP_WEBSITE_DOMAIN=kolab.local
 APP_THEME=default
 APP_TENANT_ID=5
 APP_LOCALE=en
 APP_LOCALES=
 
 APP_WITH_ADMIN=1
 APP_WITH_RESELLER=1
 APP_WITH_SERVICES=1
 APP_WITH_FILES=1
 APP_WITH_WALLET=1
 APP_WITH_SIGNUP=1
 
 APP_LDAP=0
 APP_IMAP=1
 
 APP_HEADER_CSP="connect-src 'self'; child-src 'self'; font-src 'self'; form-action 'self' data:; frame-ancestors 'self'; img-src blob: data: 'self' *; media-src 'self';"
 APP_HEADER_XFO=sameorigin
 
 ASSET_URL=https://kolab.local
 
 WEBMAIL_URL=/roundcubemail/
 SUPPORT_URL=/support
 
 LOG_CHANNEL=stdout
 LOG_SLOW_REQUESTS=5
 LOG_DEPRECATIONS_CHANNEL=null
 LOG_LEVEL=debug
 
 DB_CONNECTION=mysql
 DB_DATABASE=kolabdev
 DB_HOST=127.0.0.1
 DB_PASSWORD=simple123
 DB_ROOT_PASSWORD=simple123
 DB_PORT=3306
 DB_USERNAME=kolabdev
 
 BROADCAST_DRIVER=redis
 CACHE_DRIVER=redis
 
 QUEUE_CONNECTION=redis
 
 SESSION_DRIVER=file
 SESSION_LIFETIME=120
 
 MFA_DSN=mysql://roundcube:simple123@127.0.0.1/roundcube
 MFA_TOTP_DIGITS=6
 MFA_TOTP_INTERVAL=30
 MFA_TOTP_DIGEST=sha1
 
 IMAP_URI=localhost:11143
 IMAP_HOST=localhost
 IMAP_PORT=11143
 IMAP_GUAM_PORT=11143
 IMAP_ADMIN_LOGIN=cyrus-admin
 IMAP_ADMIN_PASSWORD=simple123
 IMAP_VERIFY_HOST=false
 IMAP_VERIFY_PEER=false
 IMAP_WITH_GROUPWARE_DEFAULT_FOLDERS=false
 
 SMTP_HOST=localhost
 SMTP_PORT=10587
 
 MEET_SERVER_URLS=https://127.0.0.1:6443/meetmedia/api/
 MEET_SERVER_VERIFY_TLS=false
 
 MEET_WEBRTC_LISTEN_IP='127.0.0.1'
 MEET_PUBLIC_DOMAIN=kolab.local
 MEET_LISTENING_HOST=127.0.0.1
 
 
 PGP_ENABLE=true
 PGP_BINARY=/usr/bin/gpg
 PGP_AGENT=/usr/bin/gpg-agent
 PGP_GPGCONF=/usr/bin/gpgconf
 PGP_LENGTH=
 
 REDIS_HOST=localhost
 REDIS_PASSWORD=null
 REDIS_PORT=6379
 
 OCTANE_HTTP_HOST=kolab.local
 SWOOLE_PACKAGE_MAX_LENGTH=10485760
 
 MAIL_MAILER=smtp
 MAIL_HOST=localhost
 MAIL_PORT=587
 MAIL_USERNAME="noreply@kolab.local"
 MAIL_PASSWORD="simple123"
 MAIL_ENCRYPTION=starttls
 MAIL_FROM_ADDRESS="noreply@kolab.local"
 MAIL_FROM_NAME="kolab.local"
 MAIL_REPLYTO_ADDRESS="noreply@kolab.local"
 MAIL_REPLYTO_NAME=null
 MAIL_VERIFY_PEER='false'
 
 RATELIMIT_WHITELIST="noreply@kolab.local"
 
 DNS_TTL=3600
 DNS_SPF="v=spf1 mx -all"
 DNS_STATIC="%s.    MX  10 ext-mx01.mykolab.com."
 DNS_COPY_FROM=null
 
 MIX_ASSET_PATH='/'
 
 PASSWORD_POLICY=
 
 COMPANY_NAME=kolab.org
 COMPANY_ADDRESS=
 COMPANY_DETAILS=
 COMPANY_EMAIL=
 COMPANY_LOGO=
 COMPANY_FOOTER=
 
 VAT_COUNTRIES=CH,LI
 VAT_RATE=7.7
 
 KB_ACCOUNT_DELETE=
 KB_ACCOUNT_SUSPENDED=
 KB_PAYMENT_SYSTEM=
 
 KOLAB_SSL_CERTIFICATE=/etc/certs/kolab.local.cert
 KOLAB_SSL_CERTIFICATE_FULLCHAIN=/etc/certs/kolab.local.chain.pem
 KOLAB_SSL_CERTIFICATE_KEY=/etc/certs/kolab.local.key
 
 OPENEXCHANGERATES_API_KEY=
 FIREBASE_API_KEY=
 
 MINIO_ENDPOINT=http://localhost:9000
 MINIO_USER=minio
 MINIO_PASSWORD=simple123
 MINIO_BUCKET=kolab
 FILESYSTEM_DISK=minio
 
 TRUSTED_PROXIES="172.18.0.7/8,127.0.0.1/8"
 
 MOLLIE_KEY=
 STRIPE_KEY=
 STRIPE_PUBLIC_KEY=
 STRIPE_WEBHOOK_SECRET=
 
 APP_PASSPHRASE=simple123
 MEET_WEBHOOK_TOKEN=simple123
 MEET_SERVER_TOKEN=simple123
 APP_KEY=base64:EFXja/fHF01EMKiXW200b5zWOynbPzAHfUM78bOp+28=
 PASSPORT_PROXY_OAUTH_CLIENT_ID=5909ca4f-df7e-45fe-b355-e7c195aef117
 PASSPORT_PROXY_OAUTH_CLIENT_SECRET=3URb+3JGJM9wPuDnlUSTPOw2mqmHsoOV8NXanx9xwQM=
+PASSPORT_SYNAPSE_OAUTH_CLIENT_ID=2909ca4f-df7e-45fe-b355-e7c195aef112
+PASSPORT_SYNAPSE_OAUTH_CLIENT_SECRET=2URb+3JGJM9wPuDnlUSTPOw2mqmHsoOV8NXanx9xwQM=
 DES_KEY=kBxUM/53N9p9abusAoT0ZEAxwI2pxFz/
 
 KOLAB_GIT_REF=master
 KOLAB_GIT_REMOTE=https://git.kolab.org/source/kolab
 GIT_REF_ROUNDCUBEMAIL=dev/kolab-1.5
 GIT_REMOTE_ROUNDCUBEMAIL=https://git.kolab.org/source/roundcubemail.git
 GIT_REF_ROUNDCUBEMAIL_PLUGINS=master
 GIT_REMOTE_ROUNDCUBEMAIL_PLUGINS=https://git.kolab.org/diffusion/RPK/roundcubemail-plugins-kolab.git
 GIT_REF_CHWALA=master
 GIT_REMOTE_CHWALA=https://git.kolab.org/diffusion/C/chwala.git
 GIT_REF_SYNCROTON=master
 GIT_REMOTE_SYNCROTON=https://git.kolab.org/diffusion/S/syncroton.git
 GIT_REF_AUTOCONF=master
 GIT_REMOTE_AUTOCONF=https://git.kolab.org/diffusion/AC/autoconf.git
 GIT_REF_IRONY=master
 GIT_REMOTE_IRONY=https://git.kolab.org/source/iRony.git
 GIT_REF_FREEBUSY=master
 GIT_REMOTE_FREEBUSY=https://git.kolab.org/diffusion/F/freebusy.git
 IMAP_GIT_REF=dev/kolab-3.6
 IMAP_GIT_REMOTE=https://git.kolab.org/source/cyrus-imapd
diff --git a/ci/testctl b/ci/testctl
index 244bd65f..e3e9fe87 100755
--- a/ci/testctl
+++ b/ci/testctl
@@ -1,452 +1,453 @@
 #!/bin/bash
 
 base_dir="$(dirname $(realpath "$0"))"
 pushd "${base_dir}"
 pushd ..
 
 set -e
 
 PASSPORT_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----
 MIIJQwIBADANBgkqhkiG9w0BAQEFAASCCS0wggkpAgEAAoICAQCmYeRp7XXnPe8w
 X0iOJRpeskfUuOJ/Gqz5dsMIWFB6fPaI5/9tkMEyp+vCEF7eFXLBrXeQi6F/VNmV
 wn+dGEQhkhuDoEXr8Z4c333wLH8iOEF4WQbt/WF3ERdjmJt3vKry8B/OLNmmcK7j
 4sz828h6L2ZT6GPcbGsNukxBMcIMOpflo0SLHy4VThdo6b1Q4nD2K/PX1ypyfFao
 nj3OfHBdSVLmTgd7BvB/azYFYWHP4INY8cylZWItDXuqPlBGSU2ff2xTKY/WRco/
 djvrO9bM1WeI+8W36EeLHERru1QRpN22TgWCQ2dbLRsVrsMg8Ly6SMe8ceDXQt5C
 LKAN24jFt1UnBgr+qK1TrxkBtu5+V2WPYWhUvBLI/2qnFQh1GiWMKinWQO7rFCIC
 rRUcQBUu2AylmG0P/oPjPrjhAnxq3HguOn8cS1OeBpOH7+8tz0CeEdyVfT8maVs/
 VWRZbEb0UjFLRNU+iVEGzz3jyQuKhOJ/2WuW0mJzF3pPQ64Dl+fLyXqF1KXNoPem
 evmmRjCZWfkWAEAWd3+yRfoOxGz55vaU1qGS81lnXnP1R5TZGXon24HHS9uRwHt6
 JII+FEwgqr8K2TISDPxx7iQbXx8kcMUMBJG8aNoG73WVXmHs0uaEUsXMy9vtegeu
 //IPpNUTlbjsn8Ot+t68mTNLUZX74wIDAQABAoICAE5fZT8KVlPfJiikcWJXktTR
 aKmIj1Qs5ha6PQNUyk/wRhbWJUjge0jXtWNb37v/4WbexafGRgPbHYUAMal3kTw4
 /RHi8JzD2uUh10pHQ3mEgz5jvTJkfMEfwWMuMulTazj1KB4vnTRb9t2saz+ebZA0
 fKCAom1leoXkX+ADxrKI9Rz766EWxlfNyZQnKgCMMYabzIg6t6lm7VEO/PEjR7CB
 hfWrArYOXkG+6BrftLm9OVGv0GSGXZj4NWzLXnfFNrWvSYDg3nqhtDNxh6b2MGeb
 DGKHqipHVU/vOEGA44hOHwutM8YY5voZRJ1RjWOaUmPzPXaEM9NiEZydNaVhaEpq
 m7jNpu7S5xa2Eodt2iz2uQhnDHrYnGVCH5psal6TZAo9APWwwBOsFQ+nXwjxTeL9
 +3JL6+jrP0eqzNVhl8c0cHJnBDpSVNG734RsK8XOxmJyq3Xt8Roi3Ud7gjy/FGpv
 XgzDpkFvd5uETn1VIuAfirm7MD8RbTIZAWCgqCrE7NuXOcnBGHuC955KF8OAx8np
 8yCtlmBSXKifoIeeyu32L8s3g7md+xRuaU8yRtuClTLKG+6oRZYcaFNcVKKZzyu5
 xnxUS6Haphd5/LhgnA3ujXkkNPdmHxPvJOWYABSNFeXzNF1npL/4wFLNvppMCPR1
 v7M7AnbvyEvKm1Q2ePe9AoIBAQDigI4AJIaHeQiuqFSIWhm8NYkOZF0jfvWM7K8v
 1IAE0WATP8KbeTINS2fUYZrNFs7S66Pl1WdPH7atVoi7QVcIoFhlYYRqILETpKJr
 z0dFLIiaajzQ9kTPzhLRDGBhO3TKb7RpFndYAuxzSw1C/3JHb4crD8kDIB8xVoba
 xvsXdVssqBQgScUrj1Ff4ZPtFhqLPsWnvdBpbM6LV/2t/CnTu4qU2szJZQNGP1Qf
 gEapbuZC6YFahXDTgYFTfn/vKzyKb/Fiskz3Rs9jgY08gRxIandeUqJIEoJi+CwZ
 q6twD8qKzGhB9nxSAOwhJzDg4SyhNnRQt5X8XQWVjpxs3HxnAoIBAQC8DPsIDN5r
 7joZj5d4/k8Yg+q1ecySm9zYy9Lzf0WUFgRu9NW9UeUPRjGXhNo5VOxxB62rMZCJ
 E81ItxUVQwHH4S62ycBPbsYEapE/itS+KdEzWQP2u3HAkLD3N28snMlIhTJR8fXB
 GasWngs9Q7uB7Wk0niKa8T7fBDx9pOyjMlIPwo0lZCrUAnmjOgZ+RvvuGDgqpDdp
 h7JUxtFmsWPgBFNZtr5BTRcr5hWRoSXJgQODqpTQHjQddMWy7LCJg3qKLiKVIOd5
 +iGzhUIZzo95FYiyt8Ojdt3Y0k5J99NOrOwAPNLvbC5TTshtA144E9uwEqBbTm+S
 RtLZeVBWZ1clAoIBAQC0j26jxnpH/MBjG2Vn3Quu8a50fqWQ6mCtGvD83BXBwXcp
 YSat8gtodbgrojNZUtlFYvug+GIGvW1O+TC+tfO/uLM+/mIkiDMhSZkBAJf8GOg8
 0HvyyJ9KWSi+5XLfkBomVq4nJ/Wzf4Em16mWwzRCpjHGriq8BxtWpXeTaBQ6Ox+X
 ldWVd7lqZDGmkZju4zP91OiUM8i0gjyU8GwWCnL9iv+KcnHWCmR1134kLool/3Yn
 2SV5F+89bHvAJ5OtAXadlWeEGkcoyJYC6P/CP9pgEB9gXddoRPkUFGpzfFqKVsxL
 oW9rRicM6BdUxn08h8SgL1zCC9fQ+ga9lpY0Yf/5AoIBAH7S5k5El5EE5mwsukRg
 hqmK9jUUAtLxiR0xQYD02dEIlE7cknYPEEOf3HxKnf5Cdv+35PlrAQZhs3YR+4cO
 XNoX1TBzml434BZEZNcM43Oosi1GIHU7b3kmXCMuYK0exGVDZ296lnp3vDoRtpTH
 5GK44dYZvE7w2qz/p2g5XVqm6k80r4qDJps7XBuoW464gtnNvbuMas6iNLQWLk1q
 32fKowgDRga2XiU+FFfV7a0bdGpNFfXSGOWwxlBobpsfb/pXKP2YZmSOPEJdYfoT
 pBFOY5Xcd3X8CZxcIW6jVABggP2cB8pvFEMdA/D5b4a0Zdo2ha1ulbJ6T2NZ/MN5
 CH0CggEBAMLRnxLQRCgdyrYroqdSBU85fAk0uU//rn7i/1vQG6pUy4Dq6W/yBhFV
 /Fph6c9NXHUUbM3HlvyY2Ht4aUQl8d50wsyU6enxvpdwzti6N2WXyrEX4WtVqgNP
 OKHEu+mii3m6kOfvDD97AT4hAGzCZR4lkb06t49y7ua4NRZaKTrTiG3g2uTtBR81
 /w1GtL+DNUEFzO1Iy2dscWxr76I+ZX6VlFHGneUlhyN9VJk8WHVI5xpVV9y7ay3I
 jXXFDgNqjqiSC6BU7iYpkVEKl/hvaGJU7CKLKFbxzBgseyY/7XsMHvWbwjK8a0Lm
 bakhie7hJBP7BoOup+dD5NQPlXBQ434=
 -----END PRIVATE KEY-----"
 PASSPORT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----
 MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEApmHkae115z3vMF9IjiUa
 XrJH1Ljifxqs+XbDCFhQenz2iOf/bZDBMqfrwhBe3hVywa13kIuhf1TZlcJ/nRhE
 IZIbg6BF6/GeHN998Cx/IjhBeFkG7f1hdxEXY5ibd7yq8vAfzizZpnCu4+LM/NvI
 ei9mU+hj3GxrDbpMQTHCDDqX5aNEix8uFU4XaOm9UOJw9ivz19cqcnxWqJ49znxw
 XUlS5k4Hewbwf2s2BWFhz+CDWPHMpWViLQ17qj5QRklNn39sUymP1kXKP3Y76zvW
 zNVniPvFt+hHixxEa7tUEaTdtk4FgkNnWy0bFa7DIPC8ukjHvHHg10LeQiygDduI
 xbdVJwYK/qitU68ZAbbufldlj2FoVLwSyP9qpxUIdRoljCop1kDu6xQiAq0VHEAV
 LtgMpZhtD/6D4z644QJ8atx4Ljp/HEtTngaTh+/vLc9AnhHclX0/JmlbP1VkWWxG
 9FIxS0TVPolRBs8948kLioTif9lrltJicxd6T0OuA5fny8l6hdSlzaD3pnr5pkYw
 mVn5FgBAFnd/skX6DsRs+eb2lNahkvNZZ15z9UeU2Rl6J9uBx0vbkcB7eiSCPhRM
 IKq/CtkyEgz8ce4kG18fJHDFDASRvGjaBu91lV5h7NLmhFLFzMvb7XoHrv/yD6TV
 E5W47J/DrfrevJkzS1GV++MCAwEAAQ==
 -----END PUBLIC KEY-----"
 export HOST=kolab.local
 
 export APP_WEBSITE_DOMAIN="$HOST"
 export APP_DOMAIN=$HOST
 export DES_KEY=kBxUM/53N9p9abusAoT0ZEAxwI2pxFz/
 export DB_HOST=127.0.0.1
 export KOLAB_SSL_CERTIFICATE=/etc/certs/kolab.local.cert
 export KOLAB_SSL_CERTIFICATE_KEY=/etc/certs/kolab.local.key
 export IMAP_HOST=localhost
 export IMAP_PORT=11143
 export IMAP_ADMIN_LOGIN=cyrus-admin
 export IMAP_ADMIN_PASSWORD=simple123
 export MAIL_HOST=localhost
 export MAIL_PORT=10587
 export IMAP_DEBUG=true
 export FILEAPI_WOPI_OFFICE=https://$HOST
 export CALENDAR_CALDAV_SERVER=http://localhost:11080/dav
 export KOLAB_ADDRESSBOOK_CARDDAV_SERVER=http://localhost:11080/dav
 export DB_ROOT_PASSWORD=simple123
 export DB_HKCCP_PASSWORD=simple123
 export DB_KOLAB_PASSWORD=simple123
 export DB_RC_PASSWORD=simple123
 export DB_PASSWORD=simple123
 export DB_USERNAME=kolabdev
 export DB_DATABASE=kolabdev
 export MINIO_ROOT_USER=minio
 export MINIO_ROOT_PASSWORD=simple123
 export MINIO_USER=minio
 export MINIO_PASSWORD=simple123
 export MEET_SERVER_TOKEN=simple123
 export MEET_WEBHOOK_TOKEN=simple123
 export PUBLIC_IP=127.0.0.1
 
 export CERTS_PATH=./ci/certs
 export IMAP_SPOOL_STORAGE=--mount=type=tmpfs,tmpfs-size=128M,tmpfs-mode=777,destination=/var/spool/imap,U=true,notmpcopyup
 export IMAP_LIB_STORAGE=--mount=type=tmpfs,tmpfs-size=128M,tmpfs-mode=777,destination=/var/lib/imap,U=true,notmpcopyup
 export SYNAPSE_STORAGE=--mount=type=tmpfs,tmpfs-size=128M,tmpfs-mode=777,destination=/data,U=true,notmpcopyup
 export MARIADB_STORAGE=--mount=type=tmpfs,tmpfs-size=512M,destination=/var/lib/mysql,U=true
 export REDIS_STORAGE=--mount=type=tmpfs,tmpfs-size=128M,destination=/var/lib/redis,U=true
 export MINIO_STORAGE=--mount=type=tmpfs,tmpfs-size=128M,destination=/data,U=true
-
+export PASSPORT_SYNAPSE_OAUTH_CLIENT_ID=2909ca4f-df7e-45fe-b355-e7c195aef112
+export PASSPORT_SYNAPSE_OAUTH_CLIENT_SECRET=2URb+3JGJM9wPuDnlUSTPOw2mqmHsoOV8NXanx9xwQM=
 
 export PODMAN_IGNORE_CGROUPSV1_WARNING=true
 
 PODMAN="podman"
 
 source bin/podman_shared
 
 # Teardown the currently running environment
 kolab__teardown() {
     $PODMAN pod rm --force tests
     $PODMAN pod rm --force dev
 }
 
 
 # Build all containers required for testing
 kolab__build() {
     if [[ $1 != "" ]]; then
         if declare -f "podman__build_$1" >/dev/null 2>&1; then
             podman__build_$1
         else
             podman__build docker/$1 $1
         fi
     else
         podman__build_base
         podman__build_webapp
         podman__build_meet
 
         podman__build_imap
         podman__build docker/mariadb mariadb
         podman__build docker/redis redis
         podman__build_proxy
         podman__build docker/synapse synapse
         podman__build docker/element element
 
         podman__build_roundcube
 
         podman__build docker/tests kolab-tests --ulimit nofile=65535:65535
         env CERT_DIR=ci/certs APP_DOMAIN=$HOST bin/regen-certs
     fi
 }
 
 # Setup the test environment
 kolab__setup() {
     echo "Build"
     kolab__build
     echo "Setup"
 
     export POD=tests
 
     # Create the pod first
     $PODMAN pod create --replace --name $POD
 
     podman__run_mariadb
     podman__run_redis
 
     podman__healthcheck $POD-mariadb $POD-redis
 
     podman__run_imap
 
     podman__run_webapp ci/env config.demo
     podman__healthcheck $POD-webapp
 
     podman__healthcheck $POD-imap
 
     # Ensure all commands are processed
     echo "Flushing work queue"
     $PODMAN exec -ti $POD-webapp ./artisan queue:work --stop-when-empty
 
     podman__run_minio
     podman__healthcheck $POD-minio
 
     # Validate the test environment 
     kolab__validate $POD
 }
 
 
 # "testsuite"
 # "quicktest"
 # "tests/Feature/Jobs/WalletCheckTest.php"
 kolab__test() {
     export POD=tests
     $PODMAN run -ti --pod tests --name $POD-kolab-tests --replace \
         --env-file=ci/env \
         -v ./src:/src/kolabsrc.orig:ro \
         -e APP_SERVICES_DOMAINS="localhost" \
         -e PASSPORT_PRIVATE_KEY="$PASSPORT_PRIVATE_KEY" \
         -e PASSPORT_PUBLIC_KEY="$PASSPORT_PUBLIC_KEY" \
         -e APP_URL="http://kolab.local" \
         -e APP_PUBLIC_URL="http://kolab.local" \
         -e APP_HEADER_CSP="" \
         -e APP_HEADER_XFO="" \
         -e ASSET_URL="http://kolab.local" \
         -e MEET_SERVER_URLS="http://kolab.local/meetmedia/api/" \
         kolab-tests:latest /init.sh $@
 }
 
 kolab__proxytest() {
     $PODMAN run -ti --pod tests --name $POD-proxy-tests --replace \
         -v ./ci/certs/:/etc/certs/:ro \
         --env-file=ci/env \
         -e SSL_CERTIFICATE=${KOLAB_SSL_CERTIFICATE} \
         -e SSL_CERTIFICATE_KEY=${KOLAB_SSL_CERTIFICATE_KEY} \
         kolab-proxy:latest /init.sh validate
 }
 
 kolab__lint() {
     $PODMAN run --rm -ti \
         -v ./src:/src/kolabsrc.orig:ro \
         kolab-tests:latest /init.sh lint
 }
 
 # Setup the test environment and run a complete testsuite
 kolab__testrun() {
     echo "Setup"
     kolab__setup
     echo "Test"
     kolab__test testsuite
 }
 
 # Setup the test environment and run all testsuites
 kolab__testrun_complete() {
     echo "Setup"
     kolab__setup
     echo "Test"
     kolab__test lint
     kolab__test testsuite
     kolab__rctest syncroton lint
     kolab__rctest syncroton testsuite
     kolab__rctest irony lint
     # kolab__rctest irony testsuite
     kolab__rctest roundcubemail-plugins-kolab lint
     # kolab__rctest roundcubemail-plugins-kolab testsuite
 }
 
 
 # Get a shell inside the test container to run/debug tests
 kolab__shell() {
     if [[ $1 != "" ]]; then
         POD="dev"
         container=$1
         shift
         command podman exec -ti $POD-$container /bin/bash
     else
         kolab__test shell
     fi
 }
 
 # Run the roundcube testsuite
 kolab__rctest() {
     $PODMAN run -t --pod tests --name $POD-roundcube --replace \
         -v ./ext:/src.orig:ro \
         -e APP_DOMAIN=kolab.local \
         -e DES_KEY=kBxUM/53N9p9abusAoT0ZEAxwI2pxFz/ \
         -e DB_HOST=127.0.0.1 \
         -e DB_RC_DATABASE=roundcube \
         -e DB_RC_USERNAME=roundcube \
         -e DB_RC_PASSWORD=simple123 \
         -e IMAP_HOST=localhost \
         -e IMAP_PORT=11143 \
         -e IMAP_ADMIN_LOGIN=cyrus-admin \
         -e IMAP_ADMIN_PASSWORD=simple123 \
         -e IMAP_DEBUG=false \
         -e SQL_DEBUG=false \
         -e ACTIVESYNC_DEBUG=false \
         -e RUN_MIGRATIONS=true \
         -e MAIL_HOST=localhost \
         -e MAIL_PORT=10587 \
         -e FILEAPI_WOPI_OFFICE=https://kolab.local \
         -e CALENDAR_CALDAV_SERVER=http://imap:11080/dav \
         -e KOLAB_ADDRESSBOOK_CARDDAV_SERVER=http://imap:11080/dav \
         roundcube:latest ./init.sh $@
 }
 
 # Get a shell inside the roundcube test container to run/debug tests
 kolab__rcshell() {
     $PODMAN run -ti --pod tests --name $POD-roundcube --replace \
         -v ./ext:/src.orig:ro \
         -e APP_DOMAIN=kolab.local \
         -e DES_KEY=kBxUM/53N9p9abusAoT0ZEAxwI2pxFz/ \
         -e DB_HOST=127.0.0.1 \
         -e DB_RC_DATABASE=roundcube \
         -e DB_RC_USERNAME=roundcube \
         -e DB_RC_PASSWORD=simple123 \
         -e IMAP_HOST=localhost \
         -e IMAP_PORT=11143 \
         -e IMAP_ADMIN_LOGIN=cyrus-admin \
         -e IMAP_ADMIN_PASSWORD=simple123 \
         -e MAIL_HOST=localhost \
         -e MAIL_PORT=10587 \
         -e FILEAPI_WOPI_OFFICE=https://kolab.local \
         -e CALENDAR_CALDAV_SERVER=http://localhost:11080/dav \
         -e KOLAB_ADDRESSBOOK_CARDDAV_SERVER=http://localhost:11080/dav \
         roundcube:latest ./init.sh shell
 }
 
 kolab__validate() {
     POD=$1
     $PODMAN exec $POD-imap testsaslauthd -u cyrus-admin -p simple123
     $PODMAN exec $POD-imap testsaslauthd -u "john@kolab.org" -p simple123
     # Ensure the inbox is created
     FOUND=false
     for i in {1..60}; do
         if $PODMAN exec $POD-imap bash -c 'echo "lm" | cyradm --auth PLAIN -u cyrus-admin -w simple123 --port 11143 localhost | grep "user/john@kolab.org"'; then
             echo "Found mailbox";
             FOUND=true
             break
         else
             echo "Waiting for mailbox";
             sleep 1;
         fi
     done
     if ! $FOUND; then
         echo "Failed to find the inbox for john@kolab.org"
         exit 1
     fi
 }
 
 kolab__run() {
     export POD=dev
     podman__run_$1
 }
 
 kolab__run() {
     POD=dev
     podman__run_$1
 }
 
 kolab__deploy() {
     export POD=dev
     # Create the pod first
     $PODMAN pod create \
         --replace \
         --add-host=kolab.local:127.0.0.1 \
         --publish "443:6443" \
         --publish "465:6465" \
         --publish "587:6587" \
         --publish "143:6143" \
         --publish "993:6993" \
         --publish "44444:44444/udp" \
         --publish "44444:44444/tcp" \
         --name $POD
 
     podman__run_mariadb
     podman__run_redis
 
     podman__healthcheck $POD-mariadb $POD-redis
 
     podman__run_imap
 
     podman__run_webapp ci/env config.prod
     podman__healthcheck $POD-webapp
 
     podman__healthcheck $POD-imap
 
     # Ensure all commands are processed
     echo "Flushing work queue"
     $PODMAN exec -ti $POD-webapp ./artisan queue:work --stop-when-empty
 
     $PODMAN exec $POD-webapp ./artisan user:password "admin@kolab.local" "simple123"
 
     podman__run_synapse
     podman__run_element
 
     podman__run_minio
     podman__healthcheck $POD-minio
 
     podman__run_meet
 
     podman__run_roundcube
     podman__run_proxy
 
     podman__run_postfix
     podman__run_amavis
 }
 
 
 # Monitor vue files for changes, and automatically reload the dev webapp container if anything changes.
 # Requires "entr" on the host
 kolab__watch() {
     trap 'kill $(jobs -p) 2>/dev/null' EXIT
     find src/resources/ src/app -regex '.*\.\(vue\|php\|js\)$' | entr podman exec -ti dev-webapp bash -c "/update-source.sh; ./artisan octane:reload" &
     podman exec -ti dev-webapp npm run watch
 }
 
 # Get the host to trust the generated ca
 kolab__add_ca_trust() {
     sudo trust anchor --store ci/certs/ca.cert
     sudo update-ca-trust
 }
 
 kolab__generate_mail() {
     $PODMAN run --pod=dev -t --rm kolab-utils:latest ./generatemail.py --maxAttachmentSize=3 --type=mail --count 100 --username admin@kolab.local --password simple123 --host localhost --port 11143 INBOX
 }
 
 kolab__syncroton_sync() {
     $PODMAN run -t --network=host --add-host=kolab.local:127.0.0.1 --rm kolab-utils:latest ./activesynccli.py --host kolab.local --user admin@kolab.local --password simple123 sync 38b950ebd62cd9a66929c89615d0fc04
 }
 
 kolab__logs() {
     POD=dev
     command podman logs --tail=1000 -f $POD-$1
 }
 
 kolab__db() {
     POD=dev
     $PODMAN exec -ti $POD-mariadb /bin/bash -c "mysql -h 127.0.0.1 -u kolabdev --password=simple123 kolabdev"
 }
 
 kolab__help() {
     cat <<EOF
   This is the kolab test execution utility.
   This script manages building the containers, setting up a test environment, and executing the tests in that environment, using podman.
 
   To run the kolab 4 testsuite:
     testctl testrun
 
   The following commands are available:
     setup: Build containers and setup the test pod
     test: Run tests (pass testsuite/quicktest or a path to a test starting with tests/ as argument)
     rctest: Run tests roundcube testsuite (WIP)
     shell: Get a shell in the test container
     testrun: Setup & test in one command, suitable as one shot command to run the main tests.
     deploy: Setup a test environment
 EOF
 }
 
 cmdname=$1
 shift
 # make sure we actually *did* get passed a valid function name
 if declare -f "kolab__$cmdname" >/dev/null 2>&1; then
     "kolab__$cmdname" "${@:1}"
 else
     echo "Function $cmdname not recognized" >&2
     kolab__help
     exit 1
 fi
 
diff --git a/config.demo/src/database/seeds/PassportSeeder.php b/config.demo/src/database/seeds/PassportSeeder.php
index e3551f0d..6fc68401 100644
--- a/config.demo/src/database/seeds/PassportSeeder.php
+++ b/config.demo/src/database/seeds/PassportSeeder.php
@@ -1,34 +1,49 @@
 <?php
 
 namespace Database\Seeds;
 
 use Laravel\Passport\Passport;
 use Illuminate\Database\Seeder;
 
 class PassportSeeder extends Seeder
 {
     /**
      * Run the database seeds.
      *
      * This emulates:
      * './artisan passport:client --password --name="Kolab Password Grant Client" --provider=users'
      *
      * @return void
      */
     public function run()
     {
         //Create a password grant client for the webapp
         $client = Passport::client()->forceFill([
             'user_id' => null,
             'name' => "Kolab Password Grant Client",
             'secret' => \config('auth.proxy.client_secret'),
             'provider' => 'users',
             'redirect' => 'https://' . \config('app.website_domain'),
             'personal_access_client' => 0,
             'password_client' => 1,
             'revoked' => false,
         ]);
         $client->id = \config('auth.proxy.client_id');
         $client->save();
+
+        // Create a client for synapse oauth
+        $client = Passport::client()->forceFill([
+            'user_id' => null,
+            'name' => "Synapse oauth client",
+            'secret' => \config('auth.synapse.client_secret'),
+            'provider' => 'users',
+            'redirect' => 'https://' . \config('app.website_domain') . "/_synapse/client/oidc/callback",
+            'personal_access_client' => 0,
+            'password_client' => 0,
+            'revoked' => false,
+            'allowed_scopes' => ['email'],
+        ]);
+        $client->id = \config('auth.synapse.client_id');
+        $client->save();
     }
 }
diff --git a/config.prod/src/database/seeds/PassportSeeder.php b/config.prod/src/database/seeds/PassportSeeder.php
index e3551f0d..6fc68401 100644
--- a/config.prod/src/database/seeds/PassportSeeder.php
+++ b/config.prod/src/database/seeds/PassportSeeder.php
@@ -1,34 +1,49 @@
 <?php
 
 namespace Database\Seeds;
 
 use Laravel\Passport\Passport;
 use Illuminate\Database\Seeder;
 
 class PassportSeeder extends Seeder
 {
     /**
      * Run the database seeds.
      *
      * This emulates:
      * './artisan passport:client --password --name="Kolab Password Grant Client" --provider=users'
      *
      * @return void
      */
     public function run()
     {
         //Create a password grant client for the webapp
         $client = Passport::client()->forceFill([
             'user_id' => null,
             'name' => "Kolab Password Grant Client",
             'secret' => \config('auth.proxy.client_secret'),
             'provider' => 'users',
             'redirect' => 'https://' . \config('app.website_domain'),
             'personal_access_client' => 0,
             'password_client' => 1,
             'revoked' => false,
         ]);
         $client->id = \config('auth.proxy.client_id');
         $client->save();
+
+        // Create a client for synapse oauth
+        $client = Passport::client()->forceFill([
+            'user_id' => null,
+            'name' => "Synapse oauth client",
+            'secret' => \config('auth.synapse.client_secret'),
+            'provider' => 'users',
+            'redirect' => 'https://' . \config('app.website_domain') . "/_synapse/client/oidc/callback",
+            'personal_access_client' => 0,
+            'password_client' => 0,
+            'revoked' => false,
+            'allowed_scopes' => ['email'],
+        ]);
+        $client->id = \config('auth.synapse.client_id');
+        $client->save();
     }
 }
diff --git a/docker/synapse/Dockerfile b/docker/synapse/Dockerfile
index 4147152e..84989a3c 100644
--- a/docker/synapse/Dockerfile
+++ b/docker/synapse/Dockerfile
@@ -1,47 +1,47 @@
 FROM apheleia/almalinux9
 
 ENV HOME=/opt/app-root/src
 
 
 RUN dnf -y install \
         --setopt=install_weak_deps=False \
         --setopt 'tsflags=nodocs' \
         libtiff-devel \
         libjpeg-devel \
         libzip-devel \
         freetype-devel \
         lcms2 \
         libwebp-devel \
         tcl-devel \
         tk-devel \
         python3 \
         python3-pip \
         libffi-devel \
         openssl-devel \
         sed \
         wget && \
-        pip3 install matrix-synapse && \
+        pip3 install matrix-synapse authlib && \
     dnf clean all
 
 COPY /rootfs /
 
 RUN id default || (groupadd -g 1001 default && useradd -d /opt/app-root/ -u 1001 -g 1001 default)
 
-RUN PATHS=(/opt/app-root/src) && \
+RUN PATHS=(/opt/app-root/src /etc/pki/ca-trust/extracted/ /etc/pki/ca-trust/source/anchors/) && \
     mkdir -p ${PATHS[@]} && \
     chmod -R 777 ${PATHS[@]} && \
     chown -R 1001:0 ${PATHS[@]} && \
     chmod -R g=u ${PATHS[@]}
 
 USER 1001
 
 WORKDIR ${HOME}
 
 VOLUME /data/
 
 # Synapse just always hits the 10s timeout and get's killed anyways, so let's be quick about it.
 STOPSIGNAL SIGKILL
 
 CMD ["/opt/app-root/src/init.sh"]
 
 EXPOSE 8008/tcp
diff --git a/docker/synapse/rootfs/opt/app-root/src/homeserver.yaml b/docker/synapse/rootfs/opt/app-root/src/homeserver.yaml
index 0c0139e4..7ae200d5 100644
--- a/docker/synapse/rootfs/opt/app-root/src/homeserver.yaml
+++ b/docker/synapse/rootfs/opt/app-root/src/homeserver.yaml
@@ -1,147 +1,157 @@
 server_name: "APP_DOMAIN"
 public_baseurl: "https://APP_DOMAIN"
 pid_file: /opt/app-root/src/homeserver.pid
 listeners:
   - port: 8008
     tls: false
     type: http
     x_forwarded: true
     bind_addresses: ['::']
     resources:
       - names: [client, federation]
         compress: false
 database:
   name: sqlite3
   args:
     database: /data/homeserver.db
 log_config: "/opt/app-root/src/log.config"
 web_client: False
 soft_file_limit: 0
 # We have no registration
 # registration_shared_secret: "REGISTRATION_SHARED_SECRET"
 # We just use a default derived from the signing key
 # macaroon_secret_key: "MACAROON_SECRET_KEY"
 # Only required for consent forms that we don't use
 # form_secret: "FORM_SECRET"
 report_stats: false
 enable_metrics: false
 signing_key_path: "/data/signing.key"
 old_signing_keys: {}
 key_refresh_interval: "1d"
 trusted_key_servers: []
 
 ## Performance ##
 event_cache_size: "10K"
 
 ## Ratelimiting ##
 rc_messages_per_second: 0.2
 rc_message_burst_count: 10.0
 federation_rc_window_size: 1000
 federation_rc_sleep_limit: 10
 federation_rc_sleep_delay: 500
 federation_rc_reject_limit: 50
 federation_rc_concurrent: 3
     
 ## Files ##
 media_store_path: /data/media_store
 max_upload_size: 50M
 max_image_pixels: 32M
 dynamic_thumbnails: false
 
 # media_retention:
 #   local_media_lifetime: 90d
 #   remote_media_lifetime: 14d
     
 # List of thumbnail to precalculate when an image is uploaded.
 thumbnail_sizes:
 - width: 32
   height: 32
   method: crop
 - width: 96
   height: 96
   method: crop
 - width: 320
   height: 240
   method: scale
 - width: 640
   height: 480
   method: scale
 - width: 800
   height: 600
   method: scale
 
 url_preview_enabled: False
 max_spider_size: "10M"
     
 ## Captcha ##
 enable_registration_captcha: False
     
 ## Turn ##
 turn_uris: [TURN_URIS]
 turn_shared_secret: "TURN_SHARED_SECRET"
 turn_user_lifetime: "1h"
 turn_allow_guests: false
     
 ## Registration ##
 enable_registration: false
 enable_registration_without_verification: false
 bcrypt_rounds: 12
 allow_guest_access: false
 enable_group_creation: false
 inhibit_user_in_use_error: true
 
 user_directory:
   enabled: false
   search_all_users: false
   prefer_local_users: false
 
 allow_public_rooms_without_auth: false
 
 enable_set_displayname: false
 enable_set_avatar_url: false
 enable_3pid_changes: false
 
 # Avoid leaking profile information
 require_auth_for_profile_requests: true
 limit_profile_requests_to_users_who_share_rooms: true
 include_profile_data_on_invite: false
     
 federation_domain_whitelist:
   - APP_DOMAIN
 
-# oidc_providers:
-#   - idp_id: kolab
-#     idp_name: "Kolab"
-#     issuer: "https://127.0.0.1:8443/auth/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 }}"
+sso:
+  client_whitelist:
+    - https://APP_DOMAIN/
+  update_profile_information: true
+
+oidc_providers:
+  - idp_id: kolab
+    idp_name: "Kolab"
+    discover: false
+    issuer: "https://APP_DOMAIN"
+    authorization_endpoint: "https://APP_DOMAIN/authorize"
+    #These connections go over localhost, but must still be https (otherwise it doesn't work). Also the certificate must match, so we can't use 127.0.0.1.
+    token_endpoint: "https://APP_DOMAIN:6443/oauth/token"
+    userinfo_endpoint: "https://APP_DOMAIN:6443/api/oauth/info"
+    client_id: "SYNAPSE_OAUTH_CLIENT_ID"
+    client_secret: "SYNAPSE_OAUTH_CLIENT_SECRET"
+    client_auth_method: client_secret_post
+    allow_existing_users: true
+    allow_registration: false
+    scopes: ['oauth']
+    user_mapping_provider:
+      config:
+        subject_claim: "id"
+        email_template: "{{ user.email }}"
+        display_name_template: "{{ user.settings.first_name }}"
 
-modules:
-- module: kolab_auth_provider.KolabAuthProvider
-  config:
-    kolab_url: "KOLAB_URL"
  
     
 ## API Configuration ##
 
 # app_service_config_files:
 #   - /config/hookshot.yaml
 
 expire_access_token: false
 
 password_config:
    enabled: true
    localdb_enabled: false
 
 # Configure a default retention policy (can be overriden ber room)
 retention:
   allowed_lifetime_min: 1d
   allowed_lifetime_max: 1y
   default_policy:
     min_lifetime: 1d
     max_lifetime: 1y
diff --git a/docker/synapse/rootfs/opt/app-root/src/init.sh b/docker/synapse/rootfs/opt/app-root/src/init.sh
index 62351d45..371ae9df 100755
--- a/docker/synapse/rootfs/opt/app-root/src/init.sh
+++ b/docker/synapse/rootfs/opt/app-root/src/init.sh
@@ -1,11 +1,18 @@
 #!/bin/bash
 set -e
 
+if [[ -f /etc/certs/ca.cert ]]; then
+    cp /etc/certs/ca.cert /etc/pki/ca-trust/source/anchors/
+    update-ca-trust
+fi
+
 sed -i -r \
     -e "s|APP_DOMAIN|$APP_DOMAIN|g" \
     -e "s|KOLAB_URL|$KOLAB_URL|g" \
     -e "s|TURN_SHARED_SECRET|$TURN_SHARED_SECRET|g" \
     -e "s|TURN_URIS|$TURN_URIS|g" \
+    -e "s|SYNAPSE_OAUTH_CLIENT_ID|$SYNAPSE_OAUTH_CLIENT_ID|g" \
+    -e "s|SYNAPSE_OAUTH_CLIENT_SECRET|$SYNAPSE_OAUTH_CLIENT_SECRET|g" \
     /opt/app-root/src/homeserver.yaml
 
 exec synctl --no-daemonize start ${CONFIGFILE:-/opt/app-root/src/homeserver.yaml}
diff --git a/kolabctl b/kolabctl
index 71aac4ae..1a112dd7 100755
--- a/kolabctl
+++ b/kolabctl
@@ -1,425 +1,435 @@
 #!/bin/bash
 
 set -e
 
 CONFIG=${CONFIG:-"config.prod"}
 export HOST=${HOST:-"kolab.local"}
 OPENEXCHANGERATES_API_KEY=${OPENEXCHANGERATES_API_KEY}
 FIREBASE_API_KEY=${FIREBASE_API_KEY}
 PUBLIC_IP=${PUBLIC_IP:-"127.0.0.1"}
 
 export CERTS_PATH=./docker/certs
 
 export POD=kolab-prod
 export IMAP_SPOOL_STORAGE="--mount=type=volume,src=$POD-imap-spool,destination=/var/spool/imap,U=true"
 export IMAP_LIB_STORAGE="--mount=type=volume,src=$POD-imap-lib,destination=/var/lib/imap,U=true"
 export POSTFIX_SPOOL_STORAGE="--mount=type=volume,src=$POD-postfix-spool,destination=/var/spool/imap,U=true"
 export POSTFIX_LIB_STORAGE="--mount=type=volume,src=$POD-postfix-lib,destination=/var/lib/imap,U=true"
 export SYNAPSE_STORAGE="--mount=type=volume,src=$POD-synapse-data,destination=/data,U=true"
 export MARIADB_STORAGE="--mount=type=volume,src=$POD-mariadb-data,destination=/var/lib/mysql,U=true"
 export REDIS_STORAGE="--mount=type=volume,src=$POD-redis-data,destination=/var/lib/redis,U=true"
 export MINIO_STORAGE="--mount=type=volume,src=$POD-minio-data,destination=/data,U=true"
 
 export PODMAN_IGNORE_CGROUPSV1_WARNING=true
 
 source bin/podman_shared
 
 
 __export_env() {
     source src/.env
     export APP_WEBSITE_DOMAIN
     export APP_DOMAIN
     export DB_HOST
     export IMAP_HOST
     export IMAP_PORT
     export IMAP_ADMIN_LOGIN
     export IMAP_ADMIN_PASSWORD
     export MAIL_HOST
     export MAIL_PORT
     export IMAP_DEBUG
     export FILEAPI_WOPI_OFFICE
     export CALENDAR_CALDAV_SERVER
     export KOLAB_ADDRESSBOOK_CARDDAV_SERVER
     export DB_ROOT_PASSWORD
     export DB_USERNAME
     export DB_PASSWORD
     export DB_DATABASE
     export MINIO_USER
     export MINIO_PASSWORD
     export PASSPORT_PRIVATE_KEY
     export PASSPORT_PUBLIC_KEY
     export DES_KEY
     export MEET_SERVER_TOKEN
     export MEET_WEBHOOK_TOKEN
     export KOLAB_SSL_CERTIFICATE
     export KOLAB_SSL_CERTIFICATE_FULLCHAIN
     export KOLAB_SSL_CERTIFICATE_KEY
     export PUBLIC_IP
 }
 
 kolab__configure() {
     if [[ "$1" == "--force" ]]; then
         rm src/.env
     fi
 
     # Generate the .env once with all the necessary secrets
     if [[ -f src/.env ]]; then
         echo "src/.env already exists, not regenerating"
         return
     fi
 
     cp "$CONFIG/src/.env" src/.env
 
     if [[ -z $ADMIN_PASSWORD ]]; then
         echo "Please enter your new admin password for the admin@$HOST user:"
         read -r ADMIN_PASSWORD
     fi
 
     if [[ -z $PUBLIC_IP ]]; then
         PUBLIC_IP=$(ip -o route get to 8.8.8.8 | sed -n 's/.*src \([0-9.]\+\).*/\1/p')
     fi
 
     # Generate random secrets
     if ! grep -q "COTURN_STATIC_SECRET" src/.env; then
         COTURN_STATIC_SECRET=$(openssl rand -hex 32);
         echo "COTURN_STATIC_SECRET=${COTURN_STATIC_SECRET}" >> src/.env
     fi
 
     if ! grep -q "MEET_WEBHOOK_TOKEN" src/.env; then
         MEET_WEBHOOK_TOKEN=$(openssl rand -hex 32);
         echo "MEET_WEBHOOK_TOKEN=${MEET_WEBHOOK_TOKEN}" >> src/.env
     fi
 
     if ! grep -q "MEET_SERVER_TOKEN" src/.env; then
         MEET_SERVER_TOKEN=$(openssl rand -hex 32);
         echo "MEET_SERVER_TOKEN=${MEET_SERVER_TOKEN}" >> src/.env
     fi
 
     if ! grep -q "APP_KEY=base64:" src/.env; then
         APP_KEY=$(openssl rand -base64 32);
         echo "APP_KEY=base64:${APP_KEY}" >> src/.env
     fi
 
     if ! grep -q "PASSPORT_PROXY_OAUTH_CLIENT_ID=" src/.env; then
         PASSPORT_PROXY_OAUTH_CLIENT_ID=$(uuidgen);
         echo "PASSPORT_PROXY_OAUTH_CLIENT_ID=${PASSPORT_PROXY_OAUTH_CLIENT_ID}" >> src/.env
     fi
 
     if ! grep -q "PASSPORT_PROXY_OAUTH_CLIENT_SECRET=" src/.env; then
         PASSPORT_PROXY_OAUTH_CLIENT_SECRET=$(openssl rand -base64 32);
         echo "PASSPORT_PROXY_OAUTH_CLIENT_SECRET=${PASSPORT_PROXY_OAUTH_CLIENT_SECRET}" >> src/.env
     fi
 
+    if ! grep -q "PASSPORT_SYNAPSE_OAUTH_CLIENT_ID=" src/.env; then
+        PASSPORT_SYNAPSE_OAUTH_CLIENT_ID=$(uuidgen);
+        echo "PASSPORT_SYNAPSE_OAUTH_CLIENT_ID=${PASSPORT_SYNAPSE_OAUTH_CLIENT_ID}" >> src/.env
+    fi
+
+    if ! grep -q "PASSPORT_SYNAPSE_OAUTH_CLIENT_SECRET=" src/.env; then
+        PASSPORT_SYNAPSE_OAUTH_CLIENT_SECRET=$(openssl rand -base64 32);
+        echo "PASSPORT_SYNAPSE_OAUTH_CLIENT_SECRET=${PASSPORT_SYNAPSE_OAUTH_CLIENT_SECRET}" >> src/.env
+    fi
+
     if ! grep -q "PASSPORT_PUBLIC_KEY=|PASSPORT_PRIVATE_KEY=" src/.env; then
         PASSPORT_PRIVATE_KEY=$(openssl genrsa 4096);
         echo "PASSPORT_PRIVATE_KEY=\"${PASSPORT_PRIVATE_KEY}\"" >> src/.env
 
         PASSPORT_PUBLIC_KEY=$(echo "$PASSPORT_PRIVATE_KEY" | openssl rsa -pubout 2>/dev/null)
         echo "PASSPORT_PUBLIC_KEY=\"${PASSPORT_PUBLIC_KEY}\"" >> src/.env
     fi
 
     if ! grep -q "DES_KEY=" src/.env; then
         DES_KEY=$(openssl rand -base64 24);
         echo "DES_KEY=${DES_KEY}" >> src/.env
     fi
 
     # Customize configuration
     sed -i \
         -e "s/{{ host }}/${HOST}/g" \
         -e "s/{{ openexchangerates_api_key }}/${OPENEXCHANGERATES_API_KEY}/g" \
         -e "s/{{ firebase_api_key }}/${FIREBASE_API_KEY}/g" \
         -e "s/{{ public_ip }}/${PUBLIC_IP}/g" \
         -e "s/{{ admin_password }}/${ADMIN_PASSWORD}/g" \
         src/.env
 
     if [ -f /etc/letsencrypt/live/${HOST}/cert.pem ]; then
         echo "Using the available letsencrypt certificate for ${HOST}"
         cat >> src/.env << EOF
 KOLAB_SSL_CERTIFICATE=/etc/letsencrypt/live/${HOST}/cert.pem
 KOLAB_SSL_CERTIFICATE_FULLCHAIN=/etc/letsencrypt/live/${HOST}/fullchain.pem
 KOLAB_SSL_CERTIFICATE_KEY=/etc/letsencrypt/live/${HOST}/privkey.pem
 EOF
     fi
 }
 
 kolab__deploy() {
     if [[ -z $ADMIN_PASSWORD ]]; then
         echo "Please enter your new admin password for the admin@$HOST user:"
         read -r ADMIN_PASSWORD
     fi
     echo "Deploying $CONFIG on $HOST"
 
     if [[ ! -f src/.env ]]; then
         echo "Missing src/.env file, run 'kolabctl configure' to generate"
         exit 1
     fi
 
     if [[ "$1" == "--reset" ]]; then
         kolab__reset --force
     fi
 
     __export_env
 
     podman volume create $POD-imap-spool --ignore -l=kolab
     podman volume create $POD-imap-lib --ignore -l=kolab
     podman volume create $POD-postfix-spool --ignore -l=kolab
     podman volume create $POD-postfix-lib --ignore -l=kolab
     podman volume create $POD-synapse-data --ignore -l=kolab
     podman volume create $POD-mariadb-data --ignore -l=kolab
     podman volume create $POD-redis-data --ignore -l=kolab
     podman volume create $POD-minio-data --ignore -l=kolab
 
     kolab__build
 
     # Create the pod first
     $PODMAN pod create \
         --replace \
         --add-host=$HOST:127.0.0.1 \
         --publish "443:6443" \
         --publish "465:6465" \
         --publish "587:6587" \
         --publish "143:6143" \
         --publish "993:6993" \
         --publish "44444:44444/udp" \
         --publish "44444:44444/tcp" \
         --name $POD
 
     podman__run_mariadb
     podman__run_redis
 
     podman__healthcheck $POD-mariadb $POD-redis
 
     # Make imap available to the webapp seeder, but don't expect it to be healthy until it can authenticate against the webapp
     podman__run_imap
 
     podman__run_webapp src/.env $CONFIG
     podman__healthcheck $POD-webapp
 
     podman__healthcheck $POD-imap
 
     # Ensure all commands are processed
     echo "Flushing work queue"
     $PODMAN exec -ti $POD-webapp ./artisan queue:work --stop-when-empty
 
     if [[ -n $ADMIN_PASSWORD ]]; then
         podman exec $POD-webapp ./artisan user:password "admin@$APP_DOMAIN" "$ADMIN_PASSWORD"
     fi
 
     podman__run_synapse
     podman__run_element
 
     podman__run_minio
     podman__healthcheck $POD-minio
 
     podman__run_meet
 
     podman__run_roundcube
     podman__run_postfix
     podman__run_amavis
     podman__run_collabora
     podman__run_proxy
 }
 
 kolab__reset() {
     if [[ "$1" == "--force" ]]; then
         REPLY="y"
     else
         read -p "Are you sure? This will delete the pod including all data. Type y to confirm." -n 1 -r
         echo
     fi
     if [[ "$REPLY" =~ ^[Yy]$ ]];
     then
         podman pod rm --force $POD
         volumes=($(podman volume ls -f name=$POD | awk '{if (NR > 1) print $2}'))
         for v in "${volumes[@]}"
         do
             podman volume rm --force $v
         done
     fi
 }
 
 kolab__start() {
     podman pod start $POD
 }
 
 kolab__stop() {
     podman pod stop $POD
 }
 
 kolab__update() {
     kolab__stop
 
     podman pull quay.io/sclorg/mariadb-105-c9s
     podman pull minio/minio:latest
     podman pull almalinux:9
 
     kolab__build
 
     kolab__start
 }
 
 kolab__backup() {
     backup_path="$(pwd)/backup/"
     mkdir -p "$backup_path"
 
     echo "Stopping containers"
     kolab__stop
 
     echo "Backing up volumes"
     volumes=($(podman volume ls -f name=$POD | awk '{if (NR > 1) print $2}'))
     for v in "${volumes[@]}"
     do
         podman export -o="$backup_path/$v.tar"
     done
 
     echo "Restarting containers"
     kolab__start
 }
 
 kolab__restore() {
     backup_path="$(pwd)/backup/"
 
     echo "Stopping containers"
     kolab__stop
 
     # We currently expect the volumes to exist.
     # We could alternatively create volumes form existing tar files
     # for f in backup/*.tar; do
     #     echo "$(basename $f .tar)" ;
     # done
 
     echo "Restoring volumes"
     volumes=($(podman volume ls -f name=$POD | awk '{if (NR > 1) print $2}'))
     for v in "${volumes[@]}"
     do
         podman import $v "$backup_path/$v.tar"
     done
     echo "Restarting containers"
     kolab__start
 }
 
 kolab__selfcheck() {
     set -e
 
     APP_DOMAIN=$(grep APP_DOMAIN src/.env | tail -n1 | sed "s/APP_DOMAIN=//")
     if [ -z "$ADMIN_PASSWORD" ]; then
         ADMIN_PASSWORD="simple123"
     fi
     if [ -z "$ADMIN_USER" ]; then
         ADMIN_USER="admin@$APP_DOMAIN"
     fi
 
     echo "Checking for containers"
     podman__is_ready $POD-imap
     podman__is_ready $POD-mariadb
     podman__is_ready $POD-redis
     podman__is_ready $POD-webapp
     podman__is_ready $POD-minio
     podman__is_ready $POD-meet
     podman__is_ready $POD-roundcube
     podman__is_ready $POD-postfix
     podman__is_ready $POD-amavis
     podman__is_ready $POD-collabora
     podman__is_ready $POD-proxy
     echo "All containers are available"
 
     # We skip mollie and openexchange
     podman exec $POD-webapp env APP_DEBUG=false ./artisan status:health --check DB --check Redis --check IMAP --check Roundcube --check Meet --check DAV
     podman exec $POD-postfix testsaslauthd -u "$ADMIN_USER" -p "$ADMIN_PASSWORD"
     podman exec $POD-imap testsaslauthd -u "$ADMIN_USER" -p "$ADMIN_PASSWORD"
 
     # podman run -ti --rm utils ./mailtransporttest.py --sender-username "$ADMIN_USER" --sender-password "$ADMIN_PASSWORD" --sender-host "127.0.0.1" --recipient-username "$ADMIN_USER" --recipient-password "$ADMIN_PASSWORD" --recipient-host "127.0.0.1" --recipient-port "11143"
 
     # podman run -ti --rm  utils ./kolabendpointtester.py --verbose --host "$APP_DOMAIN" --dav "https://$APP_DOMAIN/dav/" --imap "$APP_DOMAIN" --activesync "$APP_DOMAIN"  --user "$ADMIN_USER" --password "$ADMIN_PASSWORD"
 
     echo "All tests have passed!"
 }
 
 kolab__ps() {
     command podman ps
 }
 
 kolab__exec() {
     container=$1
     shift
     command podman exec -ti $POD-$container $@
 }
 
 kolab__run() {
     __export_env
     podman__run_$1
 }
 
 kolab__build() {
     if [[ $1 != "" ]]; then
         podman__build_$1
     else
         podman__build_base
         podman__build_webapp
         podman__build_meet
 
         podman__build_imap
         podman__build docker/mariadb mariadb
         podman__build docker/redis redis
         podman__build_proxy
         podman__build docker/synapse synapse
         podman__build docker/element element
         podman__build_roundcube
 
         podman__build_utils
         podman__build_postfix
         podman__build_amavis
         podman__build_collabora
         env CERT_DIR=docker/certs APP_DOMAIN=$HOST bin/regen-certs
     fi
 }
 
 kolab__cyradm() {
     # command podman exec -ti $POD-imap cyradm --auth PLAIN -u admin@kolab.local -w simple123  --port 11143 localhost
     if [[ "$@" ]]; then
         command podman exec -ti $POD-imap echo "$@" | cyradm --auth PLAIN -u $(grep IMAP_ADMIN_LOGIN src/.env | cut -d '=' -f 2 ) -w $(grep IMAP_ADMIN_PASSWORD src/.env | cut -d '=' -f 2 )  --port 11143 localhost
     else
         command podman exec -ti $POD-imap cyradm --auth PLAIN -u $(grep IMAP_ADMIN_LOGIN src/.env | cut -d '=' -f 2 ) -w $(grep IMAP_ADMIN_PASSWORD src/.env | cut -d '=' -f 2 )  --port 11143 localhost
     fi
 }
 
 kolab__shell() {
     kolab__exec $1 /bin/bash
 }
 
 kolab__run() {
     __export_env
     podman__run_$1
 }
 
 kolab__logs() {
     command podman logs -f $POD-$1
 }
 
 kolab__help() {
     cat <<EOF
   This is the kolab commandline utility.
 
   The following commands are available:
     deploy: Deploy kolab
     start: Start all containers
     stop: Stop all containers
     update: This will update all containers.
     backup: Create a backup in backup/
     restore: Restore a backup from backup/
     selfcheck: Run a selfcheck to ensure kolab is functional
 EOF
 }
 
 cmdname=$1
 shift
 
 # make sure we actually *did* get passed a valid function name
 if declare -f "kolab__$cmdname" >/dev/null 2>&1; then
     "kolab__$cmdname" "${@:1}"
 else
     echo "Function $cmdname not recognized" >&2
     kolab__help
     exit 1
 fi
 
diff --git a/src/app/Auth/IdentityEntity.php b/src/app/Auth/IdentityEntity.php
new file mode 100644
index 00000000..10f93b8c
--- /dev/null
+++ b/src/app/Auth/IdentityEntity.php
@@ -0,0 +1,42 @@
+<?php
+
+namespace App\Auth;
+
+use App\User;
+use League\OAuth2\Server\Entities\Traits\EntityTrait;
+use OpenIDConnect\Claims\Traits\WithClaims;
+use OpenIDConnect\Interfaces\IdentityEntityInterface;
+
+class IdentityEntity implements IdentityEntityInterface
+{
+    use EntityTrait;
+    use WithClaims;
+
+    /**
+     * The user to collect the additional information for
+     */
+    protected User $user;
+
+    /**
+     * The identity repository creates this entity and provides the user id
+     * @param mixed $identifier
+     */
+    public function setIdentifier($identifier): void
+    {
+        $this->identifier = $identifier;
+        $this->user = User::findOrFail($identifier);
+    }
+
+    /**
+     * When building the id_token, this entity's claims are collected
+     */
+    public function getClaims(): array
+    {
+        // TODO: Other claims
+        // TODO: Should we use this in AuthController::oauthUserInfo() for some de-duplicaton?
+
+        return [
+            'email' => $this->user->email,
+        ];
+    }
+}
diff --git a/src/app/Auth/IdentityRepository.php b/src/app/Auth/IdentityRepository.php
new file mode 100644
index 00000000..0a37a287
--- /dev/null
+++ b/src/app/Auth/IdentityRepository.php
@@ -0,0 +1,17 @@
+<?php
+
+namespace App\Auth;
+
+use OpenIDConnect\Interfaces\IdentityEntityInterface;
+use OpenIDConnect\Interfaces\IdentityRepositoryInterface;
+
+class IdentityRepository implements IdentityRepositoryInterface
+{
+    public function getByIdentifier(string $identifier): IdentityEntityInterface
+    {
+        $identityEntity = new IdentityEntity();
+        $identityEntity->setIdentifier($identifier);
+
+        return $identityEntity;
+    }
+}
diff --git a/src/app/Auth/PassportClient.php b/src/app/Auth/PassportClient.php
index 507d5ee7..5cb208dc 100644
--- a/src/app/Auth/PassportClient.php
+++ b/src/app/Auth/PassportClient.php
@@ -1,29 +1,27 @@
 <?php
 
 namespace App\Auth;
 
 use Illuminate\Database\Eloquent\Collection;
 
 /**
  * Passport Client extended with allowed scopes
  */
 class PassportClient extends \Laravel\Passport\Client
 {
     /** @var array<string, string> The attributes that should be cast */
     protected $casts = [
         'allowed_scopes' => 'array',
     ];
 
     /**
      * The allowed scopes for tokens instantiated by this client
-     *
-     * @return Array
-     * */
+     */
     public function getAllowedScopes(): array
     {
         if ($this->allowed_scopes) {
             return $this->allowed_scopes;
         }
         return [];
     }
 }
diff --git a/src/app/Console/Kernel.php b/src/app/Console/Kernel.php
index 49eaca27..30dc6a18 100644
--- a/src/app/Console/Kernel.php
+++ b/src/app/Console/Kernel.php
@@ -1,55 +1,58 @@
 <?php
 
 namespace App\Console;
 
 use Illuminate\Console\Scheduling\Schedule;
 use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
 
 class Kernel extends ConsoleKernel
 {
     /**
      * Define the application's command schedule.
      *
      * @param Schedule $schedule The application's command schedule
      */
     protected function schedule(Schedule $schedule): void
     {
         // This imports countries and the current set of IPv4 and IPv6 networks allocated to countries.
         $schedule->command('data:import')->dailyAt('05:00');
 
         // This notifies users about coming password expiration
         $schedule->command('password:retention')->dailyAt('06:00');
 
         // This applies wallet charges
         $schedule->command('wallet:charge')->everyFourHours();
 
         // This removes deleted storage files/file chunks from the filesystem
         $schedule->command('fs:expunge')->hourly();
 
         // This cleans up IMAP ACL for deleted users/etc.
         //$schedule->command('imap:cleanup')->dailyAt('03:00');
 
         // This notifies users about an end of the trial period
         $schedule->command('wallet:trial-end')->dailyAt('07:00');
 
         // This collects some statistics into the database
         $schedule->command('data:stats:collector')->dailyAt('23:00');
 
         // https://laravel.com/docs/10.x/upgrade#redis-cache-tags
         $schedule->command('cache:prune-stale-tags')->hourly();
+
+        // This removes passport expired/revoked tokens and auth codes from the database
+        $schedule->command('passport:purge')->dailyAt('06:30');
     }
 
     /**
      * Register the commands for the application.
      */
     protected function commands(): void
     {
         $this->load(__DIR__ . '/Commands');
 
         if (\app('env') == 'local') {
             $this->load(__DIR__ . '/Development');
         }
 
         include base_path('routes/console.php');
     }
 }
diff --git a/src/app/Http/Controllers/API/AuthController.php b/src/app/Http/Controllers/API/AuthController.php
index 6f23a6c8..9302c6d0 100644
--- a/src/app/Http/Controllers/API/AuthController.php
+++ b/src/app/Http/Controllers/API/AuthController.php
@@ -1,197 +1,277 @@
 <?php
 
 namespace App\Http\Controllers\API;
 
 use App\Http\Controllers\Controller;
 use App\User;
 use Illuminate\Http\Request;
 use Illuminate\Support\Facades\Auth;
 use Illuminate\Support\Facades\Validator;
 use Laravel\Passport\TokenRepository;
 use Laravel\Passport\RefreshTokenRepository;
+use League\OAuth2\Server\AuthorizationServer;
+use Psr\Http\Message\ServerRequestInterface;
+use Nyholm\Psr7\Response as Psr7Response;
 
 class AuthController extends Controller
 {
     /**
      * Get the authenticated User
      *
      * @return \Illuminate\Http\JsonResponse
      */
     public function info()
     {
         $user = Auth::guard()->user();
 
         if (!empty(request()->input('refresh'))) {
             return $this->refreshAndRespond(request(), $user);
         }
 
         $response = V4\UsersController::userResponse($user);
 
         return response()->json($response);
     }
 
     /**
      * Helper method for other controllers with user auto-logon
      * functionality
      *
      * @param \App\User   $user         User model object
      * @param string      $password     Plain text password
      * @param string|null $secondFactor Second factor code if available
      */
     public static function logonResponse(User $user, string $password, string $secondFactor = null)
     {
         $proxyRequest = Request::create('/oauth/token', 'POST', [
             'username' => $user->email,
             'password' => $password,
             'grant_type' => 'password',
             'client_id' => \config('auth.proxy.client_id'),
             'client_secret' => \config('auth.proxy.client_secret'),
             'scope' => 'api',
             'secondfactor' => $secondFactor
         ]);
         $proxyRequest->headers->set('X-Client-IP', request()->ip());
 
         $tokenResponse = app()->handle($proxyRequest);
 
         return self::respondWithToken($tokenResponse, $user);
     }
 
     /**
      * Get an oauth token via given credentials.
      *
      * @param \Illuminate\Http\Request $request The API request.
      *
      * @return \Illuminate\Http\JsonResponse
      */
     public function login(Request $request)
     {
         $v = Validator::make(
             $request->all(),
             [
                 'email' => 'required|min:3',
                 'password' => 'required|min:1',
             ]
         );
 
         if ($v->fails()) {
             return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
         }
 
         $user = \App\User::where('email', $request->email)->first();
 
         if (!$user) {
             return response()->json(['status' => 'error', 'message' => self::trans('auth.failed')], 401);
         }
 
         return self::logonResponse($user, $request->password, $request->secondfactor);
     }
 
+    /**
+     * Approval request for the oauth authorization endpoint
+     *
+     * * The user is authenticated via the regular login page
+     * * We assume implicit consent in the Authorization page
+     * * Ultimately we return an authorization code to the caller via the redirect_uri
+     *
+     * The implementation is based on Laravel\Passport\Http\Controllers\AuthorizationController
+     *
+     * @param ServerRequestInterface   $psrRequest PSR request
+     * @param \Illuminate\Http\Request $request    The API request
+     * @param AuthorizationServer      $server     Authorization server
+     *
+     * @return \Illuminate\Http\JsonResponse
+     */
+    public function oauthApprove(ServerRequestInterface $psrRequest, Request $request, AuthorizationServer $server)
+    {
+        if ($request->response_type != 'code') {
+            return self::errorResponse(422, self::trans('validation.invalidvalueof', ['attribute' => 'response_type']));
+        }
+
+        try {
+            // league/oauth2-server/src/Grant/ code expects GET parameters, but we're using POST here
+            $psrRequest = $psrRequest->withQueryParams($request->input());
+
+            $authRequest = $server->validateAuthorizationRequest($psrRequest);
+
+            $user = Auth::guard()->user();
+
+            // TODO I'm not sure if we should still execute this to deny the request
+            $authRequest->setUser(new \Laravel\Passport\Bridge\User($user->getAuthIdentifier()));
+            $authRequest->setAuthorizationApproved(true);
+
+            // This will generate a 302 redirect to the redirect_uri with the generated authorization code
+            $response = $server->completeAuthorizationRequest($authRequest, new Psr7Response());
+        } catch (\League\OAuth2\Server\Exception\OAuthServerException $e) {
+            // Note: We don't want 401 or 400 codes here, use 422 which is used in our API
+            $code = $e->getHttpStatusCode();
+            return self::errorResponse($code < 500 ? 422 : 500, $e->getMessage());
+        } catch (\Exception $e) {
+            return self::errorResponse(422, self::trans('auth.error.invalidrequest'));
+        }
+
+        return response()->json([
+                'status' => 'success',
+                'redirectUrl' => $response->getHeader('Location')[0],
+        ]);
+    }
+
+    /**
+     * Get the authenticated User information (using access token claims)
+     *
+     * @return \Illuminate\Http\JsonResponse
+     */
+    public function oauthUserInfo()
+    {
+        $user = Auth::guard()->user();
+
+        $response = [
+            // Per OIDC spec. 'sub' must be always returned
+            'sub' => $user->id,
+        ];
+
+        if ($user->tokenCan('email')) {
+            $response['email'] = $user->email;
+            $response['email_verified'] = $user->isActive();
+        }
+
+        // TODO: Other claims (https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims)
+        // address: address
+        // phone: phone_number and phone_number_verified
+        // profile: name, family_name, given_name, middle_name, nickname, preferred_username,
+        //    profile, picture, website, gender, birthdate, zoneinfo, locale, and updated_at
+
+        return response()->json($response);
+    }
+
     /**
      * Get the user (geo) location
      *
      * @return \Illuminate\Http\JsonResponse
      */
     public function location()
     {
         $ip = request()->ip();
 
         $response = [
             'ipAddress' => $ip,
             'countryCode' => \App\Utils::countryForIP($ip, ''),
         ];
 
         return response()->json($response);
     }
 
     /**
      * Log the user out (Invalidate the token)
      *
      * @return \Illuminate\Http\JsonResponse
      */
     public function logout()
     {
         $tokenId = Auth::user()->token()->id;
 
         $tokenRepository = app(TokenRepository::class);
         $refreshTokenRepository = app(RefreshTokenRepository::class);
 
         // Revoke an access token...
         $tokenRepository->revokeAccessToken($tokenId);
 
         // Revoke all of the token's refresh tokens...
         $refreshTokenRepository->revokeRefreshTokensByAccessTokenId($tokenId);
 
         return response()->json([
                 'status' => 'success',
                 'message' => self::trans('auth.logoutsuccess')
         ]);
     }
 
     /**
      * Refresh a token.
      *
      * @return \Illuminate\Http\JsonResponse
      */
     public function refresh(Request $request)
     {
         return self::refreshAndRespond($request);
     }
 
     /**
      * Refresh the token and respond with it.
      *
      * @param \Illuminate\Http\Request $request  The API request.
      * @param ?\App\User               $user     The user being authenticated
      *
      * @return \Illuminate\Http\JsonResponse
      */
     protected static function refreshAndRespond(Request $request, $user = null)
     {
         $proxyRequest = Request::create('/oauth/token', 'POST', [
             'grant_type' => 'refresh_token',
             'refresh_token' => $request->refresh_token,
             'client_id' => \config('auth.proxy.client_id'),
             'client_secret' => \config('auth.proxy.client_secret'),
         ]);
 
         $tokenResponse = app()->handle($proxyRequest);
 
         return self::respondWithToken($tokenResponse, $user);
     }
 
     /**
      * Get the token array structure.
      *
      * @param \Symfony\Component\HttpFoundation\Response $tokenResponse The response containing the token.
      * @param ?\App\User                                 $user          The user being authenticated
      *
      * @return \Illuminate\Http\JsonResponse
      */
     protected static function respondWithToken($tokenResponse, $user = null)
     {
         $data = json_decode($tokenResponse->getContent());
 
         if ($tokenResponse->getStatusCode() != 200) {
             if (isset($data->error) && $data->error == 'secondfactor' && isset($data->error_description)) {
                 $errors = ['secondfactor' => $data->error_description];
                 return response()->json(['status' => 'error', 'errors' => $errors], 422);
             }
 
             return response()->json(['status' => 'error', 'message' => self::trans('auth.failed')], 401);
         }
 
         if ($user) {
             $response = V4\UsersController::userResponse($user);
         } else {
             $response = [];
         }
 
         $response['status'] = 'success';
         $response['access_token'] = $data->access_token;
         $response['refresh_token'] = $data->refresh_token;
         $response['token_type'] = 'bearer';
         $response['expires_in'] = $data->expires_in;
 
         return response()->json($response);
     }
 }
diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php
index 00e11703..c366e68d 100644
--- a/src/app/Providers/AppServiceProvider.php
+++ b/src/app/Providers/AppServiceProvider.php
@@ -1,168 +1,168 @@
 <?php
 
 namespace App\Providers;
 
 use Illuminate\Database\Query\Builder;
 use Illuminate\Support\Facades\Blade;
 use Illuminate\Support\Facades\Http;
 use Illuminate\Support\Facades\Schema;
 use Illuminate\Support\ServiceProvider;
 use Laravel\Passport\Passport;
 
 class AppServiceProvider extends ServiceProvider
 {
     /**
      * Register any application services.
      */
     public function register(): void
     {
-        Passport::enablePasswordGrant();
+        // This must be here, not in PassportServiceProvider
         Passport::ignoreRoutes();
     }
 
     /**
      * Load the override config and apply it
      *
      * Create a config/override.php file with content like this:
      * return [
      *   'imap.uri' => 'overrideValue1',
      *   'queue.connections.database.table' => 'overrideValue2',
      * ];
      */
     private function applyOverrideConfig(): void
     {
         $overrideConfig = (array) \config('override');
         foreach (array_keys($overrideConfig) as $key) {
             \config([$key => $overrideConfig[$key]]);
         }
     }
 
     /**
      * Bootstrap any application services.
      */
     public function boot(): void
     {
         \App\Domain::observe(\App\Observers\DomainObserver::class);
         \App\Entitlement::observe(\App\Observers\EntitlementObserver::class);
         \App\EventLog::observe(\App\Observers\EventLogObserver::class);
         \App\Group::observe(\App\Observers\GroupObserver::class);
         \App\GroupSetting::observe(\App\Observers\GroupSettingObserver::class);
         \App\Meet\Room::observe(\App\Observers\Meet\RoomObserver::class);
         \App\PackageSku::observe(\App\Observers\PackageSkuObserver::class);
         \App\PlanPackage::observe(\App\Observers\PlanPackageObserver::class);
         \App\Resource::observe(\App\Observers\ResourceObserver::class);
         \App\ResourceSetting::observe(\App\Observers\ResourceSettingObserver::class);
         \App\SharedFolder::observe(\App\Observers\SharedFolderObserver::class);
         \App\SharedFolderAlias::observe(\App\Observers\SharedFolderAliasObserver::class);
         \App\SharedFolderSetting::observe(\App\Observers\SharedFolderSettingObserver::class);
         \App\SignupCode::observe(\App\Observers\SignupCodeObserver::class);
         \App\SignupInvitation::observe(\App\Observers\SignupInvitationObserver::class);
         \App\SignupToken::observe(\App\Observers\SignupTokenObserver::class);
         \App\Transaction::observe(\App\Observers\TransactionObserver::class);
         \App\User::observe(\App\Observers\UserObserver::class);
         \App\UserAlias::observe(\App\Observers\UserAliasObserver::class);
         \App\UserSetting::observe(\App\Observers\UserSettingObserver::class);
         \App\VerificationCode::observe(\App\Observers\VerificationCodeObserver::class);
         \App\Wallet::observe(\App\Observers\WalletObserver::class);
 
         \App\PowerDNS\Domain::observe(\App\Observers\PowerDNS\DomainObserver::class);
         \App\PowerDNS\Record::observe(\App\Observers\PowerDNS\RecordObserver::class);
 
         Schema::defaultStringLength(191);
 
         // Register some template helpers
         Blade::directive(
             'theme_asset',
             function ($path) {
                 $path = trim($path, '/\'"');
                 return "<?php echo secure_asset('themes/' . \$env['app.theme'] . '/' . '$path'); ?>";
             }
         );
 
         Builder::macro(
             'withEnvTenantContext',
             function (string $table = null) {
                 $tenantId = \config('app.tenant_id');
 
                 if ($tenantId) {
                     /** @var Builder $this */
                     return $this->where(($table ? "$table." : "") . "tenant_id", $tenantId);
                 }
 
                 /** @var Builder $this */
                 return $this->whereNull(($table ? "$table." : "") . "tenant_id");
             }
         );
 
         Builder::macro(
             'withObjectTenantContext',
             function (object $object, string $table = null) {
                 $tenantId = $object->tenant_id;
 
                 if ($tenantId) {
                     /** @var Builder $this */
                     return $this->where(($table ? "$table." : "") . "tenant_id", $tenantId);
                 }
 
                 /** @var Builder $this */
                 return $this->whereNull(($table ? "$table." : "") . "tenant_id");
             }
         );
 
         Builder::macro(
             'withSubjectTenantContext',
             function (string $table = null) {
                 if ($user = auth()->user()) {
                     $tenantId = $user->tenant_id;
                 } else {
                     $tenantId = \config('app.tenant_id');
                 }
 
                 if ($tenantId) {
                     /** @var Builder $this */
                     return $this->where(($table ? "$table." : "") . "tenant_id", $tenantId);
                 }
 
                 /** @var Builder $this */
                 return $this->whereNull(($table ? "$table." : "") . "tenant_id");
             }
         );
 
         // Query builder 'whereLike' mocro
         Builder::macro(
             'whereLike',
             function (string $column, string $search, int $mode = 0) {
                 $search = addcslashes($search, '%_');
 
                 switch ($mode) {
                     case 2:
                         $search .= '%';
                         break;
                     case 1:
                         $search = '%' . $search;
                         break;
                     default:
                         $search = '%' . $search . '%';
                 }
 
                 /** @var Builder $this */
                 return $this->where($column, 'like', $search);
             }
         );
 
         Http::macro('withSlowLog', function () {
             return Http::withOptions([
                 'on_stats' => function (\GuzzleHttp\TransferStats $stats) {
                     $threshold = \config('logging.slow_log');
                     if ($threshold && ($sec = $stats->getTransferTime()) > $threshold) {
                         $url = $stats->getEffectiveUri();
                         $method = $stats->getRequest()->getMethod();
                         \Log::warning(sprintf("[STATS] %s %s: %.4f sec.", $method, $url, $sec));
                     }
                 },
             ]);
         });
 
         $this->applyOverrideConfig();
     }
 }
diff --git a/src/app/Providers/PassportServiceProvider.php b/src/app/Providers/PassportServiceProvider.php
index 58ea5d91..b01c2d3c 100644
--- a/src/app/Providers/PassportServiceProvider.php
+++ b/src/app/Providers/PassportServiceProvider.php
@@ -1,73 +1,63 @@
 <?php
 
 namespace App\Providers;
 
 use Defuse\Crypto\Key as EncryptionKey;
 use Defuse\Crypto\Encoding as EncryptionEncoding;
-use League\OAuth2\Server\AuthorizationServer;
-use Laravel\Passport\Passport;
 use Laravel\Passport\Bridge;
+use Laravel\Passport\Passport;
+use OpenIDConnect\Laravel\PassportServiceProvider as ServiceProvider;
 
-class PassportServiceProvider extends \Laravel\Passport\PassportServiceProvider
+class PassportServiceProvider extends ServiceProvider
 {
     /**
      * Register any authentication / authorization services.
      *
      * @return void
      */
     public function boot()
     {
-        Passport::tokensCan([
+        parent::boot();
+
+        // Passport::ignoreRoutes() is in the AppServiceProvider
+        Passport::enablePasswordGrant();
+
+        $scopes = [
             'api' => 'Access API',
             'mfa' => 'Access MFA API',
             'fs' => 'Access Files API',
-        ]);
+        ];
+
+        Passport::tokensCan(array_merge($scopes, \config('openid.passport.tokens_can')));
 
         Passport::tokensExpireIn(now()->addMinutes(\config('auth.token_expiry_minutes')));
         Passport::refreshTokensExpireIn(now()->addMinutes(\config('auth.refresh_token_expiry_minutes')));
         Passport::personalAccessTokensExpireIn(now()->addMonths(6));
 
         Passport::useClientModel(\App\Auth\PassportClient::class);
         Passport::tokenModel()::observe(\App\Observers\Passport\TokenObserver::class);
     }
 
-    /**
-     * Make the authorization service instance.
-     *
-     * @return \League\OAuth2\Server\AuthorizationServer
-     */
-    public function makeAuthorizationServer()
-    {
-        return new AuthorizationServer(
-            $this->app->make(Bridge\ClientRepository::class),
-            $this->app->make(Bridge\AccessTokenRepository::class),
-            $this->app->make(Bridge\ScopeRepository::class),
-            $this->makeCryptKey('private'),
-            $this->makeEncryptionKey(app('encrypter')->getKey())
-        );
-    }
-
-
     /**
      * Create a Key instance for encrypting the refresh token
      *
      * Based on https://github.com/laravel/passport/pull/820
      *
      * @param string $keyBytes
-     * @return \Defuse\Crypto\Key
+     * @return \Defuse\Crypto\Key|string
      */
-    private function makeEncryptionKey($keyBytes)
+    protected function getEncryptionKey($keyBytes)
     {
         // First, we will encode Laravel's encryption key into a format that the Defuse\Crypto\Key class can use,
         // so we can instantiate a new Key object. We need to do this as the Key class has a private constructor method
         // which means we cannot directly instantiate the class based on our Laravel encryption key.
         $encryptionKeyAscii = EncryptionEncoding::saveBytesToChecksummedAsciiSafeString(
             EncryptionKey::KEY_CURRENT_VERSION,
             $keyBytes
         );
 
         // Instantiate a Key object so we can take advantage of significantly faster encryption/decryption
         // from https://github.com/thephpleague/oauth2-server/pull/814. The improvement is 200x-300x faster.
         return EncryptionKey::loadFromAsciiSafeString($encryptionKeyAscii);
     }
 }
diff --git a/src/app/Utils.php b/src/app/Utils.php
index 02e2fb82..e39417af 100644
--- a/src/app/Utils.php
+++ b/src/app/Utils.php
@@ -1,603 +1,617 @@
 <?php
 
 namespace App;
 
 use Carbon\Carbon;
 use Illuminate\Support\Facades\Auth;
 use Illuminate\Support\Str;
 
 /**
  * Small utility functions for App.
  */
 class Utils
 {
     // Note: Removed '0', 'O', '1', 'I' as problematic with some fonts
     public const CHARS = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ';
 
     /**
      * Exchange rates for unit tests
      */
     private static $testRates;
 
     /**
      * Count the number of lines in a file.
      *
      * Useful for progress bars.
      *
      * @param string $file The filepath to count the lines of.
      *
      * @return int
      */
     public static function countLines($file)
     {
         $fh = fopen($file, 'rb');
         $numLines = 0;
 
         while (!feof($fh)) {
             $numLines += substr_count(fread($fh, 8192), "\n");
         }
 
         fclose($fh);
 
         return $numLines;
     }
 
     /**
      * Return the country ISO code for an IP address.
      *
      * @param string $ip       IP address
      * @param string $fallback Fallback country code
      *
      * @return string
      */
     public static function countryForIP($ip, $fallback = 'CH')
     {
         if (strpos($ip, ':') === false) {
             $net = \App\IP4Net::getNet($ip);
         } else {
             $net = \App\IP6Net::getNet($ip);
         }
 
         return $net && $net->country ? $net->country : $fallback;
     }
 
     /**
      * Return the country ISO code for the current request.
      */
     public static function countryForRequest()
     {
         $request = \request();
         $ip = $request->ip();
 
         return self::countryForIP($ip);
     }
 
     /**
      * Return the number of days in the month prior to this one.
      *
      * @return int
      */
     public static function daysInLastMonth()
     {
         $start = new Carbon('first day of last month');
         $end = new Carbon('last day of last month');
 
         return $start->diffInDays($end) + 1;
     }
 
+    /**
+     * Default route handler
+     */
+    public static function defaultView()
+    {
+        // Return 404 for requests to the API end-points that do not exist
+        if (strpos(request()->path(), 'api/') === 0) {
+            return \App\Http\Controllers\Controller::errorResponse(404);
+        }
+
+        $env = self::uiEnv();
+        return view($env['view'])->with('env', $env);
+    }
+
     /**
      * Download a file from the interwebz and store it locally.
      *
      * @param string $source The source location
      * @param string $target The target location
      * @param bool $force    Force the download (and overwrite target)
      *
      * @return void
      */
     public static function downloadFile($source, $target, $force = false)
     {
         if (is_file($target) && !$force) {
             return;
         }
 
         \Log::info("Retrieving {$source}");
 
         $fp = fopen($target, 'w');
 
         $curl = curl_init();
         curl_setopt($curl, CURLOPT_URL, $source);
         curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1);
         curl_setopt($curl, CURLOPT_FILE, $fp);
         curl_exec($curl);
 
         if (curl_errno($curl)) {
             \Log::error("Request error on {$source}: " . curl_error($curl));
 
             curl_close($curl);
             fclose($fp);
 
             unlink($target);
             return;
         }
 
         curl_close($curl);
         fclose($fp);
     }
 
     /**
      * Converts an email address to lower case. Keeps the LMTP shared folder
      * addresses character case intact.
      *
      * @param string $email Email address
      *
      * @return string Email address
      */
     public static function emailToLower(string $email): string
     {
         // For LMTP shared folder address lower case the domain part only
         if (str_starts_with($email, 'shared+shared/')) {
             $pos = strrpos($email, '@');
             $domain = substr($email, $pos + 1);
             $local = substr($email, 0, strlen($email) - strlen($domain) - 1);
 
             return $local . '@' . strtolower($domain);
         }
 
         return strtolower($email);
     }
 
     /**
      * Make sure that IMAP folder access rights contains "anyone: p" permission
      *
      * @param array $acl ACL (in form of "user, permission" records)
      *
      * @return array ACL list
      */
     public static function ensureAclPostPermission(array $acl): array
     {
         foreach ($acl as $idx => $entry) {
             if (str_starts_with($entry, 'anyone,')) {
                 if (strpos($entry, 'read-only')) {
                     $acl[$idx] = 'anyone, lrsp';
                 } elseif (strpos($entry, 'read-write')) {
                     $acl[$idx] = 'anyone, lrswitednp';
                 }
 
                 return $acl;
             }
         }
 
         $acl[] = 'anyone, p';
 
         return $acl;
     }
 
     /**
      * Generate a passphrase. Not intended for use in production, so limited to environments that are not production.
      *
      * @return string
      */
     public static function generatePassphrase()
     {
         if (\config('app.env') != 'production') {
             if (\config('app.passphrase')) {
                 return \config('app.passphrase');
             }
         }
 
         $alphaLow = 'abcdefghijklmnopqrstuvwxyz';
         $alphaUp = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
         $num = '0123456789';
         $stdSpecial = '~`!@#$%^&*()-_+=[{]}\\|\'";:/?.>,<';
 
         $source = $alphaLow . $alphaUp . $num . $stdSpecial;
 
         $result = '';
 
         for ($x = 0; $x < 16; $x++) {
             $result .= substr($source, rand(0, (strlen($source) - 1)), 1);
         }
 
         return $result;
     }
 
     /**
      * Find an object that is the recipient for the specified address.
      *
      * @param string $address
      *
      * @return array
      */
     public static function findObjectsByRecipientAddress($address)
     {
         $address = \App\Utils::normalizeAddress($address);
 
         list($local, $domainName) = explode('@', $address);
 
         $domain = \App\Domain::where('namespace', $domainName)->first();
 
         if (!$domain) {
             return [];
         }
 
         $user = \App\User::where('email', $address)->first();
 
         if ($user) {
             return [$user];
         }
 
         $userAliases = \App\UserAlias::where('alias', $address)->get();
 
         if (count($userAliases) > 0) {
             $users = [];
 
             foreach ($userAliases as $userAlias) {
                 $users[] = $userAlias->user;
             }
 
             return $users;
         }
 
         $userAliases = \App\UserAlias::where('alias', "catchall@{$domain->namespace}")->get();
 
         if (count($userAliases) > 0) {
             $users = [];
 
             foreach ($userAliases as $userAlias) {
                 $users[] = $userAlias->user;
             }
 
             return $users;
         }
 
         return [];
     }
 
     /**
      * Retrieve the network ID and Type from a client address
      *
      * @param string $clientAddress The IPv4 or IPv6 address.
      *
      * @return array An array of ID and class or null and null.
      */
     public static function getNetFromAddress($clientAddress)
     {
         if (strpos($clientAddress, ':') === false) {
             $net = \App\IP4Net::getNet($clientAddress);
 
             if ($net) {
                 return [$net->id, \App\IP4Net::class];
             }
         } else {
             $net = \App\IP6Net::getNet($clientAddress);
 
             if ($net) {
                 return [$net->id, \App\IP6Net::class];
             }
         }
 
         return [null, null];
     }
 
     /**
      * Calculate the broadcast address provided a net number and a prefix.
      *
      * @param string $net A valid IPv6 network number.
      * @param int $prefix The network prefix.
      *
      * @return string
      */
     public static function ip6Broadcast($net, $prefix)
     {
         $netHex = bin2hex(inet_pton($net));
 
         // Overwriting first address string to make sure notation is optimal
         $net = inet_ntop(hex2bin($netHex));
 
         // Calculate the number of 'flexible' bits
         $flexbits = 128 - $prefix;
 
         // Build the hexadecimal string of the last address
         $lastAddrHex = $netHex;
 
         // We start at the end of the string (which is always 32 characters long)
         $pos = 31;
         while ($flexbits > 0) {
             // Get the character at this position
             $orig = substr($lastAddrHex, $pos, 1);
 
             // Convert it to an integer
             $origval = hexdec($orig);
 
             // OR it with (2^flexbits)-1, with flexbits limited to 4 at a time
             $newval = $origval | (pow(2, min(4, $flexbits)) - 1);
 
             // Convert it back to a hexadecimal character
             $new = dechex($newval);
 
             // And put that character back in the string
             $lastAddrHex = substr_replace($lastAddrHex, $new, $pos, 1);
 
             // We processed one nibble, move to previous position
             $flexbits -= 4;
             $pos -= 1;
         }
 
         // Convert the hexadecimal string to a binary string
         $lastaddrbin = hex2bin($lastAddrHex);
 
         // And create an IPv6 address from the binary string
         $lastaddrstr = inet_ntop($lastaddrbin);
 
         return $lastaddrstr;
     }
 
     /**
      * Normalize an email address.
      *
      * This means to lowercase and strip components separated with recipient delimiters.
      *
      * @param ?string $address The address to normalize
      * @param bool    $asArray Return an array with local and domain part
      *
      * @return string|array Normalized email address as string or array
      */
     public static function normalizeAddress(?string $address, bool $asArray = false)
     {
         if ($address === null || $address === '') {
             return $asArray ? ['', ''] : '';
         }
 
         $address = self::emailToLower($address);
 
         if (strpos($address, '@') === false) {
             return $asArray ? [$address, ''] : $address;
         }
 
         list($local, $domain) = explode('@', $address);
 
         if (strpos($local, '+') !== false) {
             $local = explode('+', $local)[0];
         }
 
         return $asArray ? [$local, $domain] : "{$local}@{$domain}";
     }
 
     /**
      * Returns the current user's email address or null.
      *
      * @return string
      */
     public static function userEmailOrNull(): ?string
     {
         $user = Auth::user();
 
         if (!$user) {
             return null;
         }
 
         return $user->email;
     }
 
     /**
      * Returns a random string consisting of a quantity of segments of a certain length joined.
      *
      * Example:
      *
      * ```php
      * $roomName = strtolower(\App\Utils::randStr(3, 3, '-');
      * // $roomName == '3qb-7cs-cjj'
      * ```
      *
      * @param int $length  The length of each segment
      * @param int $qty     The quantity of segments
      * @param string $join The string to use to join the segments
      *
      * @return string
      */
     public static function randStr($length, $qty = 1, $join = '')
     {
         $chars = env('SHORTCODE_CHARS', self::CHARS);
 
         $randStrs = [];
 
         for ($x = 0; $x < $qty; $x++) {
             $string = [];
 
             for ($y = 0; $y < $length; $y++) {
                 $string[] = $chars[rand(0, strlen($chars) - 1)];
             }
 
             shuffle($string);
 
             $randStrs[$x] = implode('', $string);
         }
 
         return implode($join, $randStrs);
     }
 
     /**
      * Returns a UUID in the form of an integer.
      *
      * @return int
      */
     public static function uuidInt(): int
     {
         $hex = self::uuidStr();
         $bin = pack('h*', str_replace('-', '', $hex));
         $ids = unpack('L', $bin);
         $id = array_shift($ids);
 
         return $id;
     }
 
     /**
      * Returns a UUID in the form of a string.
      *
      * @return string
      */
     public static function uuidStr(): string
     {
         return (string) Str::uuid();
     }
 
     /**
      * Create self URL
      *
      * @param string   $route    Route/Path/URL
      * @param int|null $tenantId Current tenant
      *
      * @todo Move this to App\Http\Controllers\Controller
      *
      * @return string Full URL
      */
     public static function serviceUrl(string $route, $tenantId = null): string
     {
         if (preg_match('|^https?://|i', $route)) {
             return $route;
         }
 
         $url = \App\Tenant::getConfig($tenantId, 'app.public_url');
 
         if (!$url) {
             $url = \App\Tenant::getConfig($tenantId, 'app.url');
         }
 
         return rtrim(trim($url, '/') . '/' . ltrim($route, '/'), '/');
     }
 
     /**
      * Create a configuration/environment data to be passed to
      * the UI
      *
      * @todo Move this to App\Http\Controllers\Controller
      *
      * @return array Configuration data
      */
     public static function uiEnv(): array
     {
         $countries = include resource_path('countries.php');
         $req_domain = preg_replace('/:[0-9]+$/', '', request()->getHttpHost());
         $sys_domain = \config('app.domain');
         $opts = [
             'app.name',
             'app.url',
             'app.domain',
             'app.theme',
             'app.webmail_url',
             'app.support_email',
             'app.company.copyright',
             'app.companion_download_link',
             'app.with_signup',
             'mail.from.address'
         ];
 
         $env = \app('config')->getMany($opts);
 
         $env['countries'] = $countries ?: [];
         $env['view'] = 'root';
         $env['jsapp'] = 'user.js';
 
         if ($req_domain == "admin.$sys_domain") {
             $env['jsapp'] = 'admin.js';
         } elseif ($req_domain == "reseller.$sys_domain") {
             $env['jsapp'] = 'reseller.js';
         }
 
         $env['paymentProvider'] = \config('services.payment_provider');
         $env['stripePK'] = \config('services.stripe.public_key');
 
         $env['languages'] = \App\Http\Controllers\ContentController::locales();
         $env['menu'] = \App\Http\Controllers\ContentController::menu();
 
         return $env;
     }
 
     /**
      * Set test exchange rates.
      *
      * @param array       $rates: Exchange rates
      */
     public static function setTestExchangeRates(array $rates): void
     {
         self::$testRates = $rates;
     }
 
     /**
      * Retrieve an exchange rate.
      *
      * @param string       $sourceCurrency: Currency from which to convert
      * @param string       $targetCurrency: Currency to convert to
      *
      * @return float Exchange rate
      */
     public static function exchangeRate(string $sourceCurrency, string $targetCurrency): float
     {
         if (strcasecmp($sourceCurrency, $targetCurrency) == 0) {
             return 1.0;
         }
 
         if (isset(self::$testRates[$targetCurrency])) {
             return floatval(self::$testRates[$targetCurrency]);
         }
 
         $currencyFile = resource_path("exchangerates-$sourceCurrency.php");
 
         //Attempt to find the reverse exchange rate, if we don't have the file for the source currency
         if (!file_exists($currencyFile)) {
             $rates = include resource_path("exchangerates-$targetCurrency.php");
             if (!isset($rates[$sourceCurrency])) {
                 throw new \Exception("Failed to find the reverse exchange rate for " . $sourceCurrency);
             }
             return 1.0 / floatval($rates[$sourceCurrency]);
         }
 
         $rates = include $currencyFile;
         if (!isset($rates[$targetCurrency])) {
             throw new \Exception("Failed to find exchange rate for " . $targetCurrency);
         }
 
         return floatval($rates[$targetCurrency]);
     }
 
     /**
      * A helper to display human-readable amount of money using
      * for specified currency and locale.
      *
      * @param int    $amount   Amount of money (in cents)
      * @param string $currency Currency code
      * @param string $locale   Output locale
      *
      * @return string String representation, e.g. "9.99 CHF"
      */
     public static function money(int $amount, $currency, $locale = 'de_DE'): string
     {
         $nf = new \NumberFormatter($locale, \NumberFormatter::CURRENCY);
         $result = $nf->formatCurrency(round($amount / 100, 2), $currency);
 
         // Replace non-breaking space
         return str_replace("\xC2\xA0", " ", $result);
     }
 
     /**
      * A helper to display human-readable percent value
      * for specified currency and locale.
      *
      * @param int|float $percent Percent value (0 to 100)
      * @param string    $locale  Output locale
      *
      * @return string String representation, e.g. "0 %", "7.7 %"
      */
     public static function percent(int|float $percent, $locale = 'de_DE'): string
     {
         $nf = new \NumberFormatter($locale, \NumberFormatter::PERCENT);
         $sep = $nf->getSymbol(\NumberFormatter::DECIMAL_SEPARATOR_SYMBOL);
 
         $result = sprintf('%.2F', $percent);
         $result = preg_replace('/\.00/', '', $result);
         $result = preg_replace('/(\.[0-9])0/', '\\1', $result);
         $result = str_replace('.', $sep, $result);
 
         return $result . ' %';
     }
 }
diff --git a/src/composer.json b/src/composer.json
index fd5c9d4d..61c1cc4c 100644
--- a/src/composer.json
+++ b/src/composer.json
@@ -1,87 +1,88 @@
 {
     "name": "kolab/kolab4",
     "type": "project",
     "description": "Kolab 4",
     "keywords": [
         "framework",
         "laravel"
     ],
     "license": "MIT",
     "repositories": [
         {
             "type": "vcs",
             "url": "https://git.kolab.org/diffusion/PNL/php-net_ldap3.git"
         }
     ],
     "require": {
         "php": "^8.1",
         "bacon/bacon-qr-code": "^2.0",
         "barryvdh/laravel-dompdf": "^2.0.1",
         "doctrine/dbal": "^3.6",
         "dyrynda/laravel-nullable-fields": "^4.3.0",
         "garethp/php-ews": "dev-master",
         "guzzlehttp/guzzle": "^7.8.0",
+        "jeremy379/laravel-openid-connect": "^2.3",
         "kolab/net_ldap3": "dev-master",
         "laravel/framework": "^10.15.0",
         "laravel/horizon": "^5.9",
         "laravel/octane": "^2.0",
         "laravel/passport": "^12.0",
         "laravel/tinker": "^2.8",
         "league/flysystem-aws-s3-v3": "^3.0",
         "mlocati/spf-lib": "^3.1",
         "mollie/laravel-mollie": "^2.22",
         "pear/crypt_gpg": "^1.6.6",
         "predis/predis": "^2.0",
         "sabre/vobject": "^4.5",
         "spatie/laravel-translatable": "^6.5",
         "spomky-labs/otphp": "~10.0.0",
         "stripe/stripe-php": "^10.7"
     },
     "require-dev": {
         "code-lts/doctum": "^5.5.1",
-        "laravel/dusk": "~7.9.1",
+        "laravel/dusk": "~8.2.2",
         "mockery/mockery": "^1.5",
         "larastan/larastan": "^2.0",
         "phpstan/phpstan": "^1.4",
         "phpunit/phpunit": "^9",
         "squizlabs/php_codesniffer": "^3.6"
     },
     "config": {
         "optimize-autoloader": true,
         "preferred-install": "dist",
         "sort-packages": true
     },
     "extra": {
         "laravel": {
             "dont-discover": []
         }
     },
     "autoload": {
         "psr-4": {
             "App\\": "app/"
         },
         "classmap": [
             "database/seeds",
             "include"
         ]
     },
     "autoload-dev": {
         "psr-4": {
             "Tests\\": "tests/"
         }
     },
     "minimum-stability": "stable",
     "prefer-stable": true,
     "scripts": {
         "post-autoload-dump": [
             "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
             "@php artisan package:discover --ansi"
         ],
         "post-update-cmd": [
             "@php artisan vendor:publish --tag=laravel-assets --ansi --force"
         ],
         "post-create-project-cmd": [
             "@php artisan key:generate --ansi"
         ]
     }
 }
diff --git a/src/config/auth.php b/src/config/auth.php
index aa4496a3..83c86ab0 100644
--- a/src/config/auth.php
+++ b/src/config/auth.php
@@ -1,140 +1,145 @@
 <?php
 
 return [
 
     /*
     |--------------------------------------------------------------------------
     | Authentication Defaults
     |--------------------------------------------------------------------------
     |
     | This option controls the default authentication "guard" and password
     | reset options for your application. You may change these defaults
     | as required, but they're a perfect start for most applications.
     |
     */
 
     'defaults' => [
         'guard' => 'api',
         'passwords' => 'users',
     ],
 
     /*
     |--------------------------------------------------------------------------
     | Authentication Guards
     |--------------------------------------------------------------------------
     |
     | Next, you may define every authentication guard for your application.
     | Of course, a great default configuration has been defined for you
     | here which uses session storage and the Eloquent user provider.
     |
     | All authentication drivers have a user provider. This defines how the
     | users are actually retrieved out of your database or other storage
     | mechanisms used by this application to persist your user's data.
     |
     | Supported: "session"
     |
     */
 
     'guards' => [
         'web' => [
             'driver' => 'session',
             'provider' => 'users',
         ],
 
         'api' => [
             'driver' => 'passport',
             'provider' => 'users',
         ],
     ],
 
     /*
     |--------------------------------------------------------------------------
     | User Providers
     |--------------------------------------------------------------------------
     |
     | All authentication drivers have a user provider. This defines how the
     | users are actually retrieved out of your database or other storage
     | mechanisms used by this application to persist your user's data.
     |
     | If you have multiple user tables or models you may configure multiple
     | sources which represent each model / table. These sources may then
     | be assigned to any extra authentication guards you have defined.
     |
     | Supported: "database", "eloquent"
     |
     */
 
     'providers' => [
         'users' => [
             'driver' => 'eloquent',
             'model' => App\User::class,
         ],
 
         // 'users' => [
         //     'driver' => 'database',
         //     'table' => 'users',
         // ],
     ],
 
     /*
     |--------------------------------------------------------------------------
     | Resetting Passwords
     |--------------------------------------------------------------------------
     |
     | You may specify multiple password reset configurations if you have more
     | than one user table or model in the application and you want to have
     | separate password reset settings based on the specific user types.
     |
     | The expire time is the number of minutes that each reset token will be
     | considered valid. This security feature keeps tokens short-lived so
     | they have less time to be guessed. You may change this as needed.
     |
     | The throttle setting is the number of seconds a user must wait before
     | generating more password reset tokens. This prevents the user from
     | quickly generating a very large amount of password reset tokens.
     |
     */
 
     'passwords' => [
         'users' => [
             'provider' => 'users',
             'table' => 'password_resets',
             'expire' => 60,
             'throttle' => 60,
         ],
     ],
 
     /*
     |--------------------------------------------------------------------------
     | Password Confirmation Timeout
     |--------------------------------------------------------------------------
     |
     | Here you may define the amount of seconds before a password confirmation
     | times out and the user is prompted to re-enter their password via the
     | confirmation screen. By default, the timeout lasts for three hours.
     */
 
     'password_timeout' => 10800,
 
 
     /*
     |--------------------------------------------------------------------------
     | OAuth Proxy Authentication
     |--------------------------------------------------------------------------
     |
     | If you are planning to use your application to self-authenticate as a
     | proxy, you can define the client and grant type to use here. This is
     | sometimes the case when a trusted Single Page Application doesn't
     | use a backend to send the authentication request, but instead
     | relies on the API to handle proxying the request to itself.
     |
      */
 
     'proxy' => [
         'client_id' => env('PASSPORT_PROXY_OAUTH_CLIENT_ID'),
         'client_secret' => env('PASSPORT_PROXY_OAUTH_CLIENT_SECRET'),
     ],
 
+    'synapse' => [
+        'client_id' => env('PASSPORT_SYNAPSE_OAUTH_CLIENT_ID'),
+        'client_secret' => env('PASSPORT_SYNAPSE_OAUTH_CLIENT_SECRET'),
+    ],
+
     'token_expiry_minutes' => env('OAUTH_TOKEN_EXPIRY', 60),
     'refresh_token_expiry_minutes' => env('OAUTH_REFRESH_TOKEN_EXPIRY', 30 * 24 * 60),
 ];
diff --git a/src/config/openid.php b/src/config/openid.php
new file mode 100644
index 00000000..c7659342
--- /dev/null
+++ b/src/config/openid.php
@@ -0,0 +1,85 @@
+<?php
+
+return [
+    'passport' => [
+
+        /**
+         * Place your Passport and OpenID Connect scopes here.
+         * To receive an `id_token`, you should at least provide the openid scope.
+         */
+        'tokens_can' => [
+            'openid' => 'Enable OpenID Connect',
+            'email' => 'Information about your email address',
+            // 'profile' => 'Information about your profile',
+            // 'phone' => 'Information about your phone numbers',
+            // 'address' => 'Information about your address',
+            // 'login' => 'See your login information',
+        ],
+    ],
+
+    /**
+     * Place your custom claim sets here.
+     */
+    'custom_claim_sets' => [
+        // 'login' => [
+        //     'last-login',
+        // ],
+        // 'company' => [
+        //     'company_name',
+        //     'company_address',
+        //     'company_phone',
+        //     'company_email',
+        // ],
+    ],
+
+    /**
+     * You can override the repositories below.
+     */
+    'repositories' => [
+        // 'identity' => \OpenIDConnect\Repositories\IdentityRepository::class,
+        'identity' => \App\Auth\IdentityRepository::class,
+    ],
+
+    'routes' => [
+        /**
+         * When set to true, this package will expose the OpenID Connect Discovery endpoint.
+         *  - /.well-known/openid-configuration
+         */
+        'discovery' => true,
+        /**
+         * When set to true, this package will expose the JSON Web Key Set endpoint.
+         */
+        'jwks' => false,
+         /**
+          * Optional URL to change the JWKS path to align with your custom Passport routes.
+          * Defaults to /oauth/jwks
+          */
+        'jwks_url' => '/oauth/jwks',
+    ],
+
+    /**
+     * Settings for the discovery endpoint
+     */
+    'discovery' => [
+        /**
+        * Hide scopes that aren't from the OpenID Core spec from the Discovery,
+        * default = false (all scopes are listed)
+        */
+        'hide_scopes' => false,
+    ],
+
+    /**
+     * The signer to be used
+     */
+    'signer' => \Lcobucci\JWT\Signer\Rsa\Sha256::class,
+
+    /**
+     * Optional associative array that will be used to set headers on the JWT
+     */
+    'token_headers' => [],
+
+    /**
+     * By default, microseconds are included.
+     */
+    'use_microseconds' => true,
+];
diff --git a/src/resources/js/user/routes.js b/src/resources/js/user/routes.js
index 9bb51389..4421d3e8 100644
--- a/src/resources/js/user/routes.js
+++ b/src/resources/js/user/routes.js
@@ -1,194 +1,201 @@
 import LoginComponent from '../../vue/Login'
+import AuthorizeComponent from '../../vue/Authorize'
 import LogoutComponent from '../../vue/Logout'
 import PageComponent from '../../vue/Page'
 import PasswordResetComponent from '../../vue/PasswordReset'
 import SignupComponent from '../../vue/Signup'
 
 // Here's a list of lazy-loaded components
 // Note: you can pack multiple components into the same chunk, webpackChunkName
 // is also used to get a sensible file name instead of numbers
 
 const CompanionAppInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/CompanionApp/Info')
 const CompanionAppListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/CompanionApp/List')
 const DashboardComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Dashboard')
 const DistlistInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Distlist/Info')
 const DistlistListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Distlist/List')
 const DomainInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Domain/Info')
 const DomainListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Domain/List')
 const FileInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/File/Info')
 const FileListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/File/List')
 const PaymentStatusComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Payment/Status')
 const PoliciesComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Policies')
 const ResourceInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Resource/Info')
 const ResourceListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Resource/List')
 const RoomInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Room/Info')
 const RoomListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Room/List')
 const SharedFolderInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/SharedFolder/Info')
 const SharedFolderListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/SharedFolder/List')
 const UserInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/User/Info')
 const UserListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/User/List')
 const WalletComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Wallet')
 const MeetComponent = () => import(/* webpackChunkName: "../user/meet" */ '../../vue/Meet/Room.vue')
 
 const routes = [
     {
         path: '/dashboard',
         name: 'dashboard',
         component: DashboardComponent,
         meta: { requiresAuth: true }
     },
     {
         path: '/distlist/:list',
         name: 'distlist',
         component: DistlistInfoComponent,
         meta: { requiresAuth: true, perm: 'distlists' }
     },
     {
         path: '/distlists',
         name: 'distlists',
         component: DistlistListComponent,
         meta: { requiresAuth: true, perm: 'distlists' }
     },
     {
         path: '/companion/:companion',
         name: 'companion',
         component: CompanionAppInfoComponent,
         meta: { requiresAuth: true, perm: 'companionapps' }
     },
     {
         path: '/companions',
         name: 'companions',
         component: CompanionAppListComponent,
         meta: { requiresAuth: true, perm: 'companionapps' }
     },
     {
         path: '/domain/:domain',
         name: 'domain',
         component: DomainInfoComponent,
         meta: { requiresAuth: true, perm: 'domains' }
     },
     {
         path: '/domains',
         name: 'domains',
         component: DomainListComponent,
         meta: { requiresAuth: true, perm: 'domains' }
     },
     {
         path: '/file/:file',
         name: 'file',
         component: FileInfoComponent,
         meta: { requiresAuth: true /*, perm: 'files' */ }
     },
     {
         path: '/files/:parent?',
         name: 'files',
         component: FileListComponent,
         meta: { requiresAuth: true, perm: 'files' }
     },
+    {
+        path: '/oauth/authorize',
+        name: 'authorize',
+        component: AuthorizeComponent,
+        meta: { requiresAuth: true }
+    },
     {
         path: '/login',
         name: 'login',
         component: LoginComponent
     },
     {
         path: '/logout',
         name: 'logout',
         component: LogoutComponent
     },
     {
         name: 'meet',
         path: '/meet/:room',
         component: MeetComponent,
         meta: { loading: true }
     },
     {
         path: '/password-reset/:code?',
         name: 'password-reset',
         component: PasswordResetComponent
     },
     {
         path: '/payment/status',
         name: 'payment-status',
         component: PaymentStatusComponent,
         meta: { requiresAuth: true }
     },
     {
         path: '/resource/:resource',
         name: 'resource',
         component: ResourceInfoComponent,
         meta: { requiresAuth: true, perm: 'resources' }
     },
     {
         path: '/resources',
         name: 'resources',
         component: ResourceListComponent,
         meta: { requiresAuth: true, perm: 'resources' }
     },
     {
         path: '/room/:room',
         name: 'room',
         component: RoomInfoComponent,
         meta: { requiresAuth: true, perm: 'rooms'  }
     },
     {
         path: '/rooms',
         name: 'rooms',
         component: RoomListComponent,
         meta: { requiresAuth: true, perm: 'rooms'  }
     },
     {
         path: '/policies',
         name: 'policies',
         component: PoliciesComponent,
         meta: { requiresAuth: true, perm: 'settings' }
     },
     {
         path: '/settings',
         name: 'settings',
         component: UserInfoComponent,
         meta: { requiresAuth: true }
     },
     {
         path: '/shared-folder/:folder',
         name: 'shared-folder',
         component: SharedFolderInfoComponent,
         meta: { requiresAuth: true, perm: 'folders' }
     },
     {
         path: '/shared-folders',
         name: 'shared-folders',
         component: SharedFolderListComponent,
         meta: { requiresAuth: true, perm: 'folders' }
     },
     {
         path: '/signup',
         alias: '/signup/*',
         name: 'signup',
         component: SignupComponent
     },
     {
         path: '/user/:user',
         name: 'user',
         component: UserInfoComponent,
         meta: { requiresAuth: true, perm: 'users' }
     },
     {
         path: '/users',
         name: 'users',
         component: UserListComponent,
         meta: { requiresAuth: true, perm: 'users' }
     },
     {
         path: '/wallet',
         name: 'wallet',
         component: WalletComponent,
         meta: { requiresAuth: true, perm: 'wallets' }
     },
     {
         name: '404',
         path: '*',
         component: PageComponent
     }
 ]
 
 export default routes
diff --git a/src/resources/lang/en/auth.php b/src/resources/lang/en/auth.php
index 3418d0d6..740ebee0 100644
--- a/src/resources/lang/en/auth.php
+++ b/src/resources/lang/en/auth.php
@@ -1,27 +1,28 @@
 <?php
 
 return [
 
     /*
     |--------------------------------------------------------------------------
     | Authentication Language Lines
     |--------------------------------------------------------------------------
     |
     | The following language lines are used during authentication for various
     | messages that we need to display to the user. You are free to modify
     | these language lines according to your application's requirements.
     |
     */
 
     'failed' => 'Invalid username or password.',
     'password' => 'The provided password is incorrect.',
     'throttle' => 'Too many login attempts. Please try again in :seconds seconds.',
     'logoutsuccess' => 'Successfully logged out.',
 
     'error.password' => "Invalid password",
+    'error.invalidrequest' => "Invalid authorization request.",
     'error.geolocation' => "Country code mismatch",
     'error.nofound' => "User not found",
     'error.2fa' => "Second factor failure",
     'error.2fa-generic' => "Second factor failure",
 
 ];
diff --git a/src/resources/lang/en/validation.php b/src/resources/lang/en/validation.php
index 8df02dda..3d05081a 100644
--- a/src/resources/lang/en/validation.php
+++ b/src/resources/lang/en/validation.php
@@ -1,206 +1,207 @@
 <?php
 
 return [
 
     /*
     |--------------------------------------------------------------------------
     | Validation Language Lines
     |--------------------------------------------------------------------------
     |
     | The following language lines contain the default error messages used by
     | the validator class. Some of these rules have multiple versions such
     | as the size rules. Feel free to tweak each of these messages here.
     |
     */
 
     'accepted' => 'The :attribute must be accepted.',
     'accepted_if' => 'The :attribute must be accepted when :other is :value.',
     'active_url' => 'The :attribute is not a valid URL.',
     'after' => 'The :attribute must be a date after :date.',
     'after_or_equal' => 'The :attribute must be a date after or equal to :date.',
     'alpha' => 'The :attribute must only contain letters.',
     'alpha_dash' => 'The :attribute must only contain letters, numbers, dashes and underscores.',
     'alpha_num' => 'The :attribute must only contain letters and numbers.',
     'array' => 'The :attribute must be an array.',
     'before' => 'The :attribute must be a date before :date.',
     'before_or_equal' => 'The :attribute must be a date before or equal to :date.',
     'between' => [
         'numeric' => 'The :attribute must be between :min and :max.',
         'file' => 'The :attribute must be between :min and :max kilobytes.',
         'string' => 'The :attribute must be between :min and :max characters.',
         'array' => 'The :attribute must have between :min and :max items.',
     ],
     'boolean' => 'The :attribute field must be true or false.',
     'confirmed' => 'The :attribute confirmation does not match.',
     'current_password' => 'The password is incorrect.',
     'date' => 'The :attribute is not a valid date.',
     'date_equals' => 'The :attribute must be a date equal to :date.',
     'date_format' => 'The :attribute does not match the format :format.',
     'declined' => 'The :attribute must be declined.',
     'declined_if' => 'The :attribute must be declined when :other is :value.',
     'different' => 'The :attribute and :other must be different.',
     'digits' => 'The :attribute must be :digits digits.',
     'digits_between' => 'The :attribute must be between :min and :max digits.',
     'dimensions' => 'The :attribute has invalid image dimensions.',
     'distinct' => 'The :attribute field has a duplicate value.',
     'email' => 'The :attribute must be a valid email address.',
     'ends_with' => 'The :attribute must end with one of the following: :values',
     'enum' => 'The selected :attribute is invalid.',
     'exists' => 'The selected :attribute is invalid.',
     'file' => 'The :attribute must be a file.',
     'filled' => 'The :attribute field must have a value.',
     'gt' => [
         'numeric' => 'The :attribute must be greater than :value.',
         'file' => 'The :attribute must be greater than :value kilobytes.',
         'string' => 'The :attribute must be greater than :value characters.',
         'array' => 'The :attribute must have more than :value items.',
     ],
     'gte' => [
         'numeric' => 'The :attribute must be greater than or equal :value.',
         'file' => 'The :attribute must be greater than or equal :value kilobytes.',
         'string' => 'The :attribute must be greater than or equal :value characters.',
         'array' => 'The :attribute must have :value items or more.',
     ],
     'image' => 'The :attribute must be an image.',
     'in' => 'The selected :attribute is invalid.',
     'in_array' => 'The :attribute field does not exist in :other.',
     'integer' => 'The :attribute must be an integer.',
     'ip' => 'The :attribute must be a valid IP address.',
     'ipv4' => 'The :attribute must be a valid IPv4 address.',
     'ipv6' => 'The :attribute must be a valid IPv6 address.',
     'json' => 'The :attribute must be a valid JSON string.',
     'lt' => [
         'numeric' => 'The :attribute must be less than :value.',
         'file' => 'The :attribute must be less than :value kilobytes.',
         'string' => 'The :attribute must be less than :value characters.',
         'array' => 'The :attribute must have less than :value items.',
     ],
     'lte' => [
         'numeric' => 'The :attribute must be less than or equal :value.',
         'file' => 'The :attribute must be less than or equal :value kilobytes.',
         'string' => 'The :attribute must be less than or equal :value characters.',
         'array' => 'The :attribute must not have more than :value items.',
     ],
     'max' => [
         'numeric' => 'The :attribute may not be greater than :max.',
         'file' => 'The :attribute may not be greater than :max kilobytes.',
         'string' => 'The :attribute may not be greater than :max characters.',
         'array' => 'The :attribute may not have more than :max items.',
     ],
     'mac_address' => 'The :attribute must be a valid MAC address.',
     'mimes' => 'The :attribute must be a file of type: :values.',
     'mimetypes' => 'The :attribute must be a file of type: :values.',
     'min' => [
         'numeric' => 'The :attribute must be at least :min.',
         'file' => 'The :attribute must be at least :min kilobytes.',
         'string' => 'The :attribute must be at least :min characters.',
         'array' => 'The :attribute must have at least :min items.',
     ],
     'multiple_of' => 'The :attribute must be a multiple of :value.',
     'not_in' => 'The selected :attribute is invalid.',
     'not_regex' => 'The :attribute format is invalid.',
     'numeric' => 'The :attribute must be a number.',
     'present' => 'The :attribute field must be present.',
     'prohibited' => 'The :attribute field is prohibited.',
     'prohibited_if' => 'The :attribute field is prohibited when :other is :value.',
     'prohibited_unless' => 'The :attribute field is prohibited unless :other is in :values.',
     'prohibits' => 'The :attribute field prohibits :other from being present.',
     'regex' => 'The :attribute format is invalid.',
     'regex_format' => 'The :attribute does not match the format :format.',
     'required' => 'The :attribute field is required.',
     'required_array_keys' => 'The :attribute field must contain entries for: :values.',
     'required_if' => 'The :attribute field is required when :other is :value.',
     'required_unless' => 'The :attribute field is required unless :other is in :values.',
     'required_with' => 'The :attribute field is required when :values is present.',
     'required_with_all' => 'The :attribute field is required when :values are present.',
     'required_without' => 'The :attribute field is required when :values is not present.',
     'required_without_all' => 'The :attribute field is required when none of :values are present.',
     'same' => 'The :attribute and :other must match.',
     'size' => [
         'numeric' => 'The :attribute must be :size.',
         'file' => 'The :attribute must be :size kilobytes.',
         'string' => 'The :attribute must be :size characters.',
         'array' => 'The :attribute must contain :size items.',
     ],
     'starts_with' => 'The :attribute must start with one of the following: :values',
     'string' => 'The :attribute must be a string.',
     'timezone' => 'The :attribute must be a valid timezone.',
     'unique' => 'The :attribute has already been taken.',
     'uploaded' => 'The :attribute failed to upload.',
     'url' => 'The :attribute must be a valid URL.',
     'uuid' => 'The :attribute must be a valid UUID.',
 
+    'invalidvalueof' => 'Invalid value of request property: :attribute.',
     '2fareq' => 'Second factor code is required.',
     '2fainvalid' => 'Second factor code is invalid.',
     'emailinvalid' => 'The specified email address is invalid.',
     'domaininvalid' => 'The specified domain is invalid.',
     'domainnotavailable' => 'The specified domain is not available.',
     'logininvalid' => 'The specified login is invalid.',
     'loginexists' => 'The specified login is not available.',
     'domainexists' => 'The specified domain is not available.',
     'noemailorphone' => 'The specified text is neither a valid email address nor a phone number.',
     'packageinvalid' => 'Invalid package selected.',
     'packagerequired' => 'Package is required.',
     'usernotexists' => 'Unable to find user.',
     'voucherinvalid' => 'The voucher code is invalid or expired.',
     'noextemail' => 'This user has no external email address.',
     'entryinvalid' => 'The specified :attribute is invalid.',
     'entryexists' => 'The specified :attribute is not available.',
     'minamount' => 'Minimum amount for a single payment is :amount.',
     'minamountdebt' => 'The specified amount does not cover the balance on the account.',
     'notalocaluser' => 'The specified email address does not exist.',
     'memberislist' => 'A recipient cannot be the same as the list address.',
     'listmembersrequired' => 'At least one recipient is required.',
     'spf-entry-invalid' => 'The entry format is invalid. Expected a domain name starting with a dot.',
     'sp-entry-invalid' => 'The entry format is invalid. Expected an email, domain, or part of it.',
     'acl-entry-invalid' => 'The entry format is invalid. Expected an email address.',
     'acl-permission-invalid' => 'The specified permission is invalid.',
     'file-perm-exists' => 'File permission already exists.',
     'file-perm-invalid' => 'The file permission is invalid.',
     'file-name-exists' => 'The file name already exists.',
     'file-name-invalid' => 'The file name is invalid.',
     'file-name-toolong' => 'The file name is too long.',
     'fsparentunknown' => 'Specified parent does not exist.',
     'geolockinerror' => 'The request location is not allowed.',
     'ipolicy-invalid' => 'The specified invitation policy is invalid.',
     'invalid-config-parameter' => 'The requested configuration parameter is not supported.',
     'nameexists' => 'The specified name is not available.',
     'nameinvalid' => 'The specified name is invalid.',
     'password-policy-error' => 'Specified password does not comply with the policy.',
     'invalid-limit-geo' => 'Specified configuration is invalid. Expected a list of two-letter country codes.',
     'invalid-password-policy' => 'Specified password policy is invalid.',
     'password-policy-min-len-error' => 'Minimum password length cannot be less than :min.',
     'password-policy-max-len-error' => 'Maximum password length cannot be more than :max.',
     'password-policy-last-error' => 'The minimum value for last N passwords is :last.',
     'signuptokeninvalid' => 'The signup token is invalid.',
 
     /*
     |--------------------------------------------------------------------------
     | Custom Validation Language Lines
     |--------------------------------------------------------------------------
     |
     | Here you may specify custom validation messages for attributes using the
     | convention "attribute.rule" to name the lines. This makes it quick to
     | specify a specific custom language line for a given attribute rule.
     |
     */
 
     'custom' => [
         'attribute-name' => [
             'rule-name' => 'custom-message',
         ],
     ],
 
     /*
     |--------------------------------------------------------------------------
     | Custom Validation Attributes
     |--------------------------------------------------------------------------
     |
     | The following language lines are used to swap our attribute placeholder
     | with something more reader friendly such as "E-Mail Address" instead
     | of "email". This simply helps us make our message more expressive.
     |
     */
 
     'attributes' => [],
 ];
diff --git a/src/resources/vue/Authorize.vue b/src/resources/vue/Authorize.vue
new file mode 100644
index 00000000..d17eed8f
--- /dev/null
+++ b/src/resources/vue/Authorize.vue
@@ -0,0 +1,31 @@
+<template>
+    <div class="container">
+    </div>
+</template>
+
+<script>
+    export default {
+        created() {
+            // Just auto approve for now
+            // If we wanted we could use this page to list what is being authorized,
+            // and allow the user to approve/reject.
+            // Note that in case of SSO it is also expected that there's no user interaction at all,
+            // isn't it? Maybe we should show the details once per client_id+user_id combination.
+            this.submitApproval()
+        },
+        methods: {
+            submitApproval() {
+                let props = ['client_id', 'redirect_uri', 'state', 'nonce', 'scope', 'response_type', 'response_mode']
+                let post = this.$root.pick(this.$route.query, props)
+
+                axios.post('/api/oauth/approve', post, { loading: true })
+                    .then(response => {
+                        // Follow the redirect to the external page
+                        window.location.href = response.data.redirectUrl;
+                    })
+                    .catch(this.$root.errorHandler)
+            }
+        }
+    }
+</script>
+
diff --git a/src/routes/api.php b/src/routes/api.php
index 027f6c0d..74cdf800 100644
--- a/src/routes/api.php
+++ b/src/routes/api.php
@@ -1,339 +1,342 @@
 <?php
 
 use App\Http\Controllers\API;
 use Illuminate\Http\Request;
 use Illuminate\Support\Facades\Route;
 
 /*
 |--------------------------------------------------------------------------
 | API Routes
 |--------------------------------------------------------------------------
 |
 | Here is where you can register API routes for your application. These
 | routes are loaded by the RouteServiceProvider within a group which
 | is assigned the "api" middleware group. Enjoy building your API!
 |
 */
 
+Route::post('oauth/approve', [API\AuthController::class, 'oauthApprove'])
+    ->middleware(['auth:api']);
+
 Route::group(
     [
         'middleware' => 'api',
         'prefix' => 'auth'
     ],
     function () {
         Route::post('login', [API\AuthController::class, 'login']);
 
         Route::group(
-            ['middleware' => 'auth:api'],
+            ['middleware' => ['auth:api', 'scope:api']],
             function () {
                 Route::get('info', [API\AuthController::class, 'info']);
                 Route::post('info', [API\AuthController::class, 'info']);
                 Route::get('location', [API\AuthController::class, 'location']);
                 Route::post('logout', [API\AuthController::class, 'logout']);
                 Route::post('refresh', [API\AuthController::class, 'refresh']);
             }
         );
     }
 );
 
 Route::group(
     [
         'domain' => \config('app.website_domain'),
         'middleware' => 'api',
         'prefix' => 'auth'
     ],
     function () {
         Route::post('password-policy/check', [API\PasswordPolicyController::class, 'check']);
 
         Route::post('password-reset/init', [API\PasswordResetController::class, 'init']);
         Route::post('password-reset/verify', [API\PasswordResetController::class, 'verify']);
         Route::post('password-reset', [API\PasswordResetController::class, 'reset']);
     }
 );
 
 if (\config('app.with_signup')) {
     Route::group(
         [
             'domain' => \config('app.website_domain'),
             'middleware' => 'api',
             'prefix' => 'auth'
         ],
         function () {
             Route::get('signup/domains', [API\SignupController::class, 'domains']);
             Route::post('signup/init', [API\SignupController::class, 'init']);
             Route::get('signup/invitations/{id}', [API\SignupController::class, 'invitation']);
             Route::get('signup/plans', [API\SignupController::class, 'plans']);
             Route::post('signup/validate', [API\SignupController::class, 'signupValidate']);
             Route::post('signup/verify', [API\SignupController::class, 'verify']);
             Route::post('signup', [API\SignupController::class, 'signup']);
         }
     );
 }
 
 Route::group(
     [
         'domain' => \config('app.website_domain'),
         'middleware' => ['auth:api', 'scope:mfa,api'],
         'prefix' => 'v4'
     ],
     function () {
         Route::post('auth-attempts/{id}/confirm', [API\V4\AuthAttemptsController::class, 'confirm']);
         Route::post('auth-attempts/{id}/deny', [API\V4\AuthAttemptsController::class, 'deny']);
         Route::get('auth-attempts/{id}/details', [API\V4\AuthAttemptsController::class, 'details']);
         Route::get('auth-attempts', [API\V4\AuthAttemptsController::class, 'index']);
 
         Route::post('companion/register', [API\V4\CompanionAppsController::class, 'register']);
     }
 );
 
 if (\config('app.with_files')) {
     Route::group(
         [
             'middleware' => ['auth:api', 'scope:fs,api'],
             'prefix' => 'v4'
         ],
         function () {
                 Route::apiResource('fs', API\V4\FsController::class);
                 Route::get('fs/{itemId}/permissions', [API\V4\FsController::class, 'getPermissions']);
                 Route::post('fs/{itemId}/permissions', [API\V4\FsController::class, 'createPermission']);
                 Route::put('fs/{itemId}/permissions/{id}', [API\V4\FsController::class, 'updatePermission']);
                 Route::delete('fs/{itemId}/permissions/{id}', [API\V4\FsController::class, 'deletePermission']);
         }
     );
     Route::group(
         [
             'middleware' => [],
             'prefix' => 'v4'
         ],
         function () {
                 Route::post('fs/uploads/{id}', [API\V4\FsController::class, 'upload'])
                     ->middleware(['api']);
                 Route::get('fs/downloads/{id}', [API\V4\FsController::class, 'download']);
         }
     );
 }
 
 Route::group(
     [
         'domain' => \config('app.website_domain'),
         'middleware' => ['auth:api', 'scope:api'],
         'prefix' => 'v4'
     ],
     function () {
         Route::apiResource('companions', API\V4\CompanionAppsController::class);
         // This must not be accessible with the 2fa token,
         // to prevent an attacker from pairing a new device with a stolen token.
         Route::get('companions/{id}/pairing', [API\V4\CompanionAppsController::class, 'pairing']);
 
         Route::apiResource('domains', API\V4\DomainsController::class);
         Route::get('domains/{id}/confirm', [API\V4\DomainsController::class, 'confirm']);
         Route::get('domains/{id}/skus', [API\V4\DomainsController::class, 'skus']);
         Route::get('domains/{id}/status', [API\V4\DomainsController::class, 'status']);
         Route::post('domains/{id}/config', [API\V4\DomainsController::class, 'setConfig']);
 
         Route::apiResource('groups', API\V4\GroupsController::class);
         Route::get('groups/{id}/skus', [API\V4\GroupsController::class, 'skus']);
         Route::get('groups/{id}/status', [API\V4\GroupsController::class, 'status']);
         Route::post('groups/{id}/config', [API\V4\GroupsController::class, 'setConfig']);
 
         Route::apiResource('packages', API\V4\PackagesController::class);
 
         Route::apiResource('rooms', API\V4\RoomsController::class);
         Route::post('rooms/{id}/config', [API\V4\RoomsController::class, 'setConfig']);
         Route::get('rooms/{id}/skus', [API\V4\RoomsController::class, 'skus']);
 
         Route::post('meet/rooms/{id}', [API\V4\MeetController::class, 'joinRoom'])
             ->withoutMiddleware(['auth:api', 'scope:api']);
 
         Route::apiResource('resources', API\V4\ResourcesController::class);
         Route::get('resources/{id}/skus', [API\V4\ResourcesController::class, 'skus']);
         Route::get('resources/{id}/status', [API\V4\ResourcesController::class, 'status']);
         Route::post('resources/{id}/config', [API\V4\ResourcesController::class, 'setConfig']);
 
         Route::apiResource('shared-folders', API\V4\SharedFoldersController::class);
         Route::get('shared-folders/{id}/skus', [API\V4\SharedFoldersController::class, 'skus']);
         Route::get('shared-folders/{id}/status', [API\V4\SharedFoldersController::class, 'status']);
         Route::post('shared-folders/{id}/config', [API\V4\SharedFoldersController::class, 'setConfig']);
 
         Route::apiResource('skus', API\V4\SkusController::class);
 
         Route::apiResource('users', API\V4\UsersController::class);
         Route::post('users/{id}/config', [API\V4\UsersController::class, 'setConfig']);
         Route::get('users/{id}/skus', [API\V4\UsersController::class, 'skus']);
         Route::get('users/{id}/status', [API\V4\UsersController::class, 'status']);
 
         Route::apiResource('wallets', API\V4\WalletsController::class);
         Route::get('wallets/{id}/transactions', [API\V4\WalletsController::class, 'transactions']);
         Route::get('wallets/{id}/receipts', [API\V4\WalletsController::class, 'receipts']);
         Route::get('wallets/{id}/receipts/{receipt}', [API\V4\WalletsController::class, 'receiptDownload']);
 
         Route::get('password-policy', [API\PasswordPolicyController::class, 'index']);
         Route::post('password-reset/code', [API\PasswordResetController::class, 'codeCreate']);
         Route::delete('password-reset/code/{id}', [API\PasswordResetController::class, 'codeDelete']);
 
         Route::post('payments', [API\V4\PaymentsController::class, 'store']);
         //Route::delete('payments', [API\V4\PaymentsController::class, 'cancel']);
         Route::get('payments/mandate', [API\V4\PaymentsController::class, 'mandate']);
         Route::post('payments/mandate', [API\V4\PaymentsController::class, 'mandateCreate']);
         Route::put('payments/mandate', [API\V4\PaymentsController::class, 'mandateUpdate']);
         Route::delete('payments/mandate', [API\V4\PaymentsController::class, 'mandateDelete']);
         Route::post('payments/mandate/reset', [API\V4\PaymentsController::class, 'mandateReset']);
         Route::get('payments/methods', [API\V4\PaymentsController::class, 'paymentMethods']);
         Route::get('payments/pending', [API\V4\PaymentsController::class, 'payments']);
         Route::get('payments/has-pending', [API\V4\PaymentsController::class, 'hasPayments']);
         Route::get('payments/status', [API\V4\PaymentsController::class, 'paymentStatus']);
 
         Route::get('search/self', [API\V4\SearchController::class, 'searchSelf']);
         if (\config('app.with_user_search')) {
             Route::get('search/user', [API\V4\SearchController::class, 'searchUser']);
         }
 
         Route::post('support/request', [API\V4\SupportController::class, 'request'])
             ->withoutMiddleware(['auth:api', 'scope:api'])
             ->middleware(['api']);
 
         Route::get('vpn/token', [API\V4\VPNController::class, 'token']);
     }
 );
 
 Route::group(
     [
         'domain' => \config('app.website_domain'),
         'prefix' => 'webhooks'
     ],
     function () {
         Route::post('payment/{provider}', [API\V4\PaymentsController::class, 'webhook']);
         Route::post('meet', [API\V4\MeetController::class, 'webhook']);
     }
 );
 
 if (\config('app.with_services')) {
     Route::group(
         [
             'middleware' => ['allowedHosts'],
             'prefix' => 'webhooks'
         ],
         function () {
             Route::get('nginx', [API\V4\NGINXController::class, 'authenticate']);
             Route::get('nginx-roundcube', [API\V4\NGINXController::class, 'authenticateRoundcube']);
             Route::get('nginx-httpauth', [API\V4\NGINXController::class, 'httpauth']);
             Route::post('cyrus-sasl', [API\V4\NGINXController::class, 'cyrussasl']);
             Route::post('policy/greylist', [API\V4\PolicyController::class, 'greylist']);
             Route::post('policy/ratelimit', [API\V4\PolicyController::class, 'ratelimit']);
             Route::post('policy/spf', [API\V4\PolicyController::class, 'senderPolicyFramework']);
         }
     );
 }
 
 Route::get('health/readiness', [API\V4\HealthController::class, 'readiness']);
 Route::get('health/liveness', [API\V4\HealthController::class, 'liveness']);
 
 if (\config('app.with_admin')) {
     Route::group(
         [
             'domain' => 'admin.' . \config('app.website_domain'),
             'middleware' => ['auth:api', 'admin'],
             'prefix' => 'v4',
         ],
         function () {
             Route::apiResource('domains', API\V4\Admin\DomainsController::class);
             Route::get('domains/{id}/skus', [API\V4\Admin\DomainsController::class, 'skus']);
             Route::post('domains/{id}/suspend', [API\V4\Admin\DomainsController::class, 'suspend']);
             Route::post('domains/{id}/unsuspend', [API\V4\Admin\DomainsController::class, 'unsuspend']);
 
             Route::get('eventlog/{type}/{id}', [API\V4\Admin\EventLogController::class, 'index']);
 
             Route::apiResource('groups', API\V4\Admin\GroupsController::class);
             Route::post('groups/{id}/suspend', [API\V4\Admin\GroupsController::class, 'suspend']);
             Route::post('groups/{id}/unsuspend', [API\V4\Admin\GroupsController::class, 'unsuspend']);
 
             Route::apiResource('resources', API\V4\Admin\ResourcesController::class);
             Route::apiResource('shared-folders', API\V4\Admin\SharedFoldersController::class);
             Route::apiResource('skus', API\V4\Admin\SkusController::class);
 
             Route::apiResource('users', API\V4\Admin\UsersController::class);
             Route::get('users/{id}/discounts', [API\V4\Admin\DiscountsController::class, 'userDiscounts']);
             Route::post('users/{id}/reset2FA', [API\V4\Admin\UsersController::class, 'reset2FA']);
             Route::post('users/{id}/resetGeoLock', [API\V4\Admin\UsersController::class, 'resetGeoLock']);
             Route::post('users/{id}/resync', [API\V4\Admin\UsersController::class, 'resync']);
             Route::get('users/{id}/skus', [API\V4\Admin\UsersController::class, 'skus']);
             Route::post('users/{id}/skus/{sku}', [API\V4\Admin\UsersController::class, 'setSku']);
             Route::post('users/{id}/suspend', [API\V4\Admin\UsersController::class, 'suspend']);
             Route::post('users/{id}/unsuspend', [API\V4\Admin\UsersController::class, 'unsuspend']);
 
             Route::apiResource('wallets', API\V4\Admin\WalletsController::class);
             Route::post('wallets/{id}/one-off', [API\V4\Admin\WalletsController::class, 'oneOff']);
             Route::get('wallets/{id}/receipts', [API\V4\Admin\WalletsController::class, 'receipts']);
             Route::get('wallets/{id}/receipts/{receipt}', [API\V4\Admin\WalletsController::class, 'receiptDownload']);
             Route::get('wallets/{id}/transactions', [API\V4\Admin\WalletsController::class, 'transactions']);
 
             Route::get('stats/chart/{chart}', [API\V4\Admin\StatsController::class, 'chart']);
         }
     );
 
     Route::group(
         [
             'domain' => 'admin.' . \config('app.website_domain'),
             'prefix' => 'v4',
         ],
         function () {
             Route::get('inspect-request', [API\V4\Admin\UsersController::class, 'inspectRequest']);
         }
     );
 }
 
 if (\config('app.with_reseller')) {
     Route::group(
         [
             'domain' => 'reseller.' . \config('app.website_domain'),
             'middleware' => ['auth:api', 'reseller'],
             'prefix' => 'v4',
         ],
         function () {
             Route::apiResource('domains', API\V4\Reseller\DomainsController::class);
             Route::get('domains/{id}/skus', [API\V4\Reseller\DomainsController::class, 'skus']);
             Route::post('domains/{id}/suspend', [API\V4\Reseller\DomainsController::class, 'suspend']);
             Route::post('domains/{id}/unsuspend', [API\V4\Reseller\DomainsController::class, 'unsuspend']);
 
             Route::get('eventlog/{type}/{id}', [API\V4\Reseller\EventLogController::class, 'index']);
 
             Route::apiResource('groups', API\V4\Reseller\GroupsController::class);
             Route::post('groups/{id}/suspend', [API\V4\Reseller\GroupsController::class, 'suspend']);
             Route::post('groups/{id}/unsuspend', [API\V4\Reseller\GroupsController::class, 'unsuspend']);
 
             Route::apiResource('invitations', API\V4\Reseller\InvitationsController::class);
             Route::post('invitations/{id}/resend', [API\V4\Reseller\InvitationsController::class, 'resend']);
 
             Route::post('payments', [API\V4\Reseller\PaymentsController::class, 'store']);
             Route::get('payments/mandate', [API\V4\Reseller\PaymentsController::class, 'mandate']);
             Route::post('payments/mandate', [API\V4\Reseller\PaymentsController::class, 'mandateCreate']);
             Route::put('payments/mandate', [API\V4\Reseller\PaymentsController::class, 'mandateUpdate']);
             Route::delete('payments/mandate', [API\V4\Reseller\PaymentsController::class, 'mandateDelete']);
             Route::get('payments/methods', [API\V4\Reseller\PaymentsController::class, 'paymentMethods']);
             Route::get('payments/pending', [API\V4\Reseller\PaymentsController::class, 'payments']);
             Route::get('payments/has-pending', [API\V4\Reseller\PaymentsController::class, 'hasPayments']);
 
             Route::apiResource('resources', API\V4\Reseller\ResourcesController::class);
             Route::apiResource('shared-folders', API\V4\Reseller\SharedFoldersController::class);
             Route::apiResource('skus', API\V4\Reseller\SkusController::class);
 
             Route::apiResource('users', API\V4\Reseller\UsersController::class);
             Route::get('users/{id}/discounts', [API\V4\Reseller\DiscountsController::class, 'userDiscounts']);
             Route::post('users/{id}/reset2FA', [API\V4\Reseller\UsersController::class, 'reset2FA']);
             Route::post('users/{id}/resetGeoLock', [API\V4\Reseller\UsersController::class, 'resetGeoLock']);
             Route::post('users/{id}/resync', [API\V4\Reseller\UsersController::class, 'resync']);
             Route::get('users/{id}/skus', [API\V4\Reseller\UsersController::class, 'skus']);
             Route::post('users/{id}/skus/{sku}', [API\V4\Reseller\UsersController::class, 'setSku']);
             Route::post('users/{id}/suspend', [API\V4\Reseller\UsersController::class, 'suspend']);
             Route::post('users/{id}/unsuspend', [API\V4\Reseller\UsersController::class, 'unsuspend']);
 
             Route::apiResource('wallets', API\V4\Reseller\WalletsController::class);
             Route::post('wallets/{id}/one-off', [API\V4\Reseller\WalletsController::class, 'oneOff']);
             Route::get('wallets/{id}/receipts', [API\V4\Reseller\WalletsController::class, 'receipts']);
             Route::get('wallets/{id}/receipts/{receipt}', [API\V4\Reseller\WalletsController::class, 'receiptDownload']);
             Route::get('wallets/{id}/transactions', [API\V4\Reseller\WalletsController::class, 'transactions']);
 
             Route::get('stats/chart/{chart}', [API\V4\Reseller\StatsController::class, 'chart']);
         }
     );
 }
diff --git a/src/routes/web.php b/src/routes/web.php
index e27a07c5..35582e53 100644
--- a/src/routes/web.php
+++ b/src/routes/web.php
@@ -1,71 +1,66 @@
 <?php
 
 use App\Http\Controllers;
 use Illuminate\Support\Facades\Route;
+use Laravel\Passport\Http\Controllers as PassportControllers;
 
 Route::get('204', function () {
     return response()->noContent();
 });
 
 // We can handle every URL with the default action because
 // we have client-side router (including 404 error handler).
 // This way we don't have to define any "deep link" routes here.
 Route::group(
     [
         //'domain' => \config('app.website_domain'),
     ],
     function () {
-        Route::get('content/page/{page}', Controllers\ContentController::class . '@pageContent')
+        Route::get('content/page/{page}', [Controllers\ContentController::class. 'pageContent'])
             ->where('page', '(.*)');
-        Route::get('content/faq/{page}', Controllers\ContentController::class . '@faqContent')
+        Route::get('content/faq/{page}', [Controllers\ContentController::class, 'faqContent'])
             ->where('page', '(.*)');
 
-        Route::fallback(
-            function () {
-                // Return 404 for requests to the API end-points that do not exist
-                if (strpos(request()->path(), 'api/') === 0) {
-                    return Controllers\Controller::errorResponse(404);
-                }
-
-                $env = \App\Utils::uiEnv();
-                return view($env['view'])->with('env', $env);
-            }
-        );
+        Route::fallback([\App\Utils::class, 'defaultView']);
     }
 );
 
 Route::group(
     [
         'prefix' => 'oauth'
     ],
     function () {
         // We manually specify a subset of endpoints from https://github.com/laravel/passport/blob/11.x/routes/web.php
         // after having disabled automatic routes via Passport::ignoreRoutes()
-        Route::post('/token', [
-            'uses' => '\Laravel\Passport\Http\Controllers\AccessTokenController@issueToken',
-            'as' => 'token',
-            // 'middleware' => 'throttle',
-        ]);
+        Route::post('/token', [PassportControllers\AccessTokenController::class, 'issueToken'])
+            ->name('passport.token'); // needed for .well-known/openid-configuration handler
 
         Route::middleware(['web', 'auth'])->group(function () {
-            Route::get('/tokens', [
-                'uses' => '\Laravel\Passport\Http\Controllers\AuthorizedAccessTokenController@forUser',
-                'as' => 'tokens.index',
-            ]);
+            Route::get('/tokens', [PassportControllers\AuthorizedAccessTokenController::class, 'forUser'])
+                ->name('passport.tokens.index');
 
-            Route::delete('/tokens/{token_id}', [
-                'uses' => '\Laravel\Passport\Http\Controllers\AuthorizedAccessTokenController@destroy',
-                'as' => 'tokens.destroy',
-            ]);
+            Route::delete('/tokens/{token_id}', [PassportControllers\AuthorizedAccessTokenController::class, 'destroy'])
+                ->name('passport.tokens.destroy');
         });
+
+        // TODO: Enable CORS on this endpoint, it is "SHOULD" in OIDC spec.
+        // TODO: More scopes e.g. profile
+        // TODO: This should be both GET and POST per OIDC spec. GET is recommended though.
+        Route::get('/userinfo', [Controllers\API\AuthController::class, 'oauthUserInfo'])
+            ->middleware(['auth:api', 'scope:email'])
+            ->name('openid.userinfo'); // needed for .well-known/openid-configuration handler
+
+        Route::get('/authorize', [\App\Utils::class, 'defaultView'])
+            ->name('passport.authorizations.authorize'); // needed for .well-known/openid-configuration handler
     }
 );
 
 Route::group(
     [
         'prefix' => '.well-known'
     ],
     function () {
-        Route::get('/mta-sts.txt', [Controllers\WellKnownController::class, "mtaSts"]);
+        // .well-known/openid-configuration is handled by an external package (see config/openid.php)
+        Route::get('/mta-sts.txt', [Controllers\WellKnownController::class, 'mtaSts']);
     }
 );
diff --git a/src/tests/Browser/AuthorizeTest.php b/src/tests/Browser/AuthorizeTest.php
new file mode 100644
index 00000000..5c898343
--- /dev/null
+++ b/src/tests/Browser/AuthorizeTest.php
@@ -0,0 +1,80 @@
+<?php
+
+namespace Tests\Browser;
+
+use Tests\Browser;
+use Tests\Browser\Components\Toast;
+use Tests\Browser\Pages\Home;
+use Tests\TestCaseDusk;
+
+class AuthorizeTest extends TestCaseDusk
+{
+    private $client;
+
+    /**
+     * {@inheritDoc}
+     */
+    public function setUp(): void
+    {
+        parent::setUp();
+
+        // Create a client for tests
+        $this->client = \App\Auth\PassportClient::firstOrCreate(
+            ['id' => 'test'],
+            [
+                'user_id' => null,
+                'name' => 'Test',
+                'secret' => '123',
+                'provider' => 'users',
+                'redirect' => 'https://kolab.org',
+                'personal_access_client' => 0,
+                'password_client' => 0,
+                'revoked' => false,
+                'allowed_scopes' => ['email'],
+            ]
+        );
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function tearDown(): void
+    {
+        $this->client->delete();
+
+        parent::tearDown();
+    }
+
+    /**
+     * Test /oauth/authorize page
+     */
+    public function testAuthorize(): void
+    {
+        $url = '/oauth/authorize?' . http_build_query([
+                'client_id' => $this->client->id,
+                'response_type' => 'code',
+                'scope' => 'email',
+                'state' => 'state',
+        ]);
+
+        $this->browse(function (Browser $browser) use ($url) {
+            $redirect_check = "window.location.host == 'kolab.org'"
+                . " && window.location.search.match(/^\?code=[a-f0-9]+&state=state/)";
+
+            // Unauthenticated user
+            $browser->visit($url)
+                ->on(new Home())
+                ->submitLogon('john@kolab.org', 'simple123')
+                ->waitUntil($redirect_check);
+
+            // Authenticated user
+            $browser->visit($url)
+                ->waitUntil($redirect_check);
+
+            // Error handling (invalid response_type)
+            $browser->visit('oauth/authorize?response_type=invalid')
+                ->assertErrorPage(422)
+                ->assertToast(Toast::TYPE_ERROR, 'Invalid value of request property: response_type.');
+        });
+    }
+}
diff --git a/src/tests/Browser/Components/Error.php b/src/tests/Browser/Components/Error.php
index 4214906d..cc9d3947 100644
--- a/src/tests/Browser/Components/Error.php
+++ b/src/tests/Browser/Components/Error.php
@@ -1,76 +1,77 @@
 <?php
 
 namespace Tests\Browser\Components;
 
 use Laravel\Dusk\Component as BaseComponent;
 use PHPUnit\Framework\Assert as PHPUnit;
 
 class Error extends BaseComponent
 {
     protected $code;
     protected $hint;
     protected $message;
     protected $messages_map = [
         400 => "Bad request",
         401 => "Unauthorized",
         403 => "Access denied",
         404 => "Not found",
         405 => "Method not allowed",
+        422 => "Unprocessable Content",
         500 => "Internal server error",
     ];
 
     public function __construct($code, $hint = '')
     {
         $this->code = $code;
         $this->hint = $hint;
         $this->message = $this->messages_map[$code];
     }
 
     /**
      * Get the root selector for the component.
      *
      * @return string
      */
     public function selector()
     {
         return '#error-page';
     }
 
     /**
      * Assert that the browser page contains the component.
      *
      * @param \Laravel\Dusk\Browser $browser
      *
      * @return void
      */
     public function assert($browser)
     {
         $browser->waitFor($this->selector())
             ->assertSeeIn('@code', $this->code);
 
         if ($this->hint) {
             $browser->assertSeeIn('@hint', $this->hint);
         } else {
             $browser->assertMissing('@hint');
         }
 
         $message = $browser->text('@message');
         PHPUnit::assertSame(strtolower($message), strtolower($this->message));
     }
 
     /**
      * Get the element shortcuts for the component.
      *
      * @return array
      */
     public function elements()
     {
         $selector = $this->selector();
 
         return [
             '@code' => "$selector .code",
             '@message' =>  "$selector .message",
             '@hint' =>  "$selector .hint",
         ];
     }
 }
diff --git a/src/tests/Feature/Controller/AuthTest.php b/src/tests/Feature/Controller/AuthTest.php
index 0c04e7a0..075b274f 100644
--- a/src/tests/Feature/Controller/AuthTest.php
+++ b/src/tests/Feature/Controller/AuthTest.php
@@ -1,329 +1,584 @@
 <?php
 
 namespace Tests\Feature\Controller;
 
 use App\Domain;
 use App\User;
 use Tests\TestCase;
 
 class AuthTest extends TestCase
 {
     private $expectedExpiry;
 
     /**
      * Reset all authentication guards to clear any cache users
      */
     protected function resetAuth()
     {
-        $guards = array_keys(config('auth.guards'));
-
-        foreach ($guards as $guard) {
-            $guard = $this->app['auth']->guard($guard);
-
-            if ($guard instanceof \Illuminate\Auth\SessionGuard) {
-                $guard->logout();
-            }
-        }
-
-        $protectedProperty = new \ReflectionProperty($this->app['auth'], 'guards');
-        $protectedProperty->setAccessible(true);
-        $protectedProperty->setValue($this->app['auth'], []);
+        $this->app['auth']->forgetGuards();
     }
 
     /**
      * {@inheritDoc}
      */
     public function setUp(): void
     {
         parent::setUp();
 
         $this->deleteTestUser('UsersControllerTest1@userscontroller.com');
         $this->deleteTestDomain('userscontroller.com');
 
         $this->expectedExpiry = \config('auth.token_expiry_minutes') * 60;
 
         \App\IP4Net::where('net_number', inet_pton('127.0.0.0'))->delete();
 
         $user = $this->getTestUser('john@kolab.org');
         $user->setSetting('limit_geo', null);
     }
 
     /**
      * {@inheritDoc}
      */
     public function tearDown(): void
     {
         $this->deleteTestUser('UsersControllerTest1@userscontroller.com');
         $this->deleteTestDomain('userscontroller.com');
 
         \App\IP4Net::where('net_number', inet_pton('127.0.0.0'))->delete();
 
         $user = $this->getTestUser('john@kolab.org');
         $user->setSetting('limit_geo', null);
 
         parent::tearDown();
     }
 
     /**
      * Test fetching current user info (/api/auth/info)
      */
     public function testInfo(): void
     {
         $user = $this->getTestUser('UsersControllerTest1@userscontroller.com', ['status' => User::STATUS_NEW]);
         $domain = $this->getTestDomain('userscontroller.com', [
                 'status' => Domain::STATUS_NEW,
                 'type' => Domain::TYPE_PUBLIC,
         ]);
 
         $response = $this->get("api/auth/info");
         $response->assertStatus(401);
 
         $response = $this->actingAs($user)->get("api/auth/info");
         $response->assertStatus(200);
 
         $json = $response->json();
 
         $this->assertEquals($user->id, $json['id']);
         $this->assertEquals($user->email, $json['email']);
         $this->assertEquals(User::STATUS_NEW, $json['status']);
         $this->assertTrue(is_array($json['statusInfo']));
         $this->assertTrue(is_array($json['settings']));
         $this->assertTrue(!isset($json['access_token']));
 
         // Note: Details of the content are tested in testUserResponse()
 
         // Test token refresh via the info request
         // First we log in to get the refresh token
         $post = ['email' => 'john@kolab.org', 'password' => 'simple123'];
         $user = $this->getTestUser('john@kolab.org');
         $response = $this->post("api/auth/login", $post);
         $json = $response->json();
         $response = $this->actingAs($user)
             ->post("api/auth/info?refresh=1", ['refresh_token' => $json['refresh_token']]);
         $response->assertStatus(200);
 
         $json = $response->json();
 
         $this->assertEquals('john@kolab.org', $json['email']);
         $this->assertTrue(is_array($json['statusInfo']));
         $this->assertTrue(is_array($json['settings']));
         $this->assertTrue(!empty($json['access_token']));
         $this->assertTrue(!empty($json['expires_in']));
     }
 
     /**
      * Test fetching current user location (/api/auth/location)
      */
     public function testLocation(): void
     {
         $user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
 
         // Authentication required
         $response = $this->get("api/auth/location");
         $response->assertStatus(401);
 
         $headers = ['X-Client-IP' => '127.0.0.2'];
 
         $response = $this->actingAs($user)->withHeaders($headers)->get("api/auth/location");
         $response->assertStatus(200);
 
         $json = $response->json();
 
         $this->assertSame('127.0.0.2', $json['ipAddress']);
         $this->assertSame('', $json['countryCode']);
 
         \App\IP4Net::create([
                 'net_number' => '127.0.0.0',
                 'net_broadcast' => '127.255.255.255',
                 'net_mask' => 8,
                 'country' => 'US',
                 'rir_name' => 'test',
                 'serial' => 1,
         ]);
 
         $response = $this->actingAs($user)->withHeaders($headers)->get("api/auth/location");
         $response->assertStatus(200);
 
         $json = $response->json();
 
         $this->assertSame('127.0.0.2', $json['ipAddress']);
         $this->assertSame('US', $json['countryCode']);
     }
 
     /**
      * Test /api/auth/login
      */
     public function testLogin(): string
     {
         $user = $this->getTestUser('john@kolab.org');
 
         // Request with no data
         $response = $this->post("api/auth/login", []);
         $response->assertStatus(422);
         $json = $response->json();
 
         $this->assertSame('error', $json['status']);
         $this->assertCount(2, $json['errors']);
         $this->assertArrayHasKey('email', $json['errors']);
         $this->assertArrayHasKey('password', $json['errors']);
 
         // Request with invalid password
         $post = ['email' => 'john@kolab.org', 'password' => 'wrong'];
         $response = $this->post("api/auth/login", $post);
         $response->assertStatus(401);
 
         $json = $response->json();
 
         $this->assertSame('error', $json['status']);
         $this->assertSame('Invalid username or password.', $json['message']);
 
         // Valid user+password
         $post = ['email' => 'john@kolab.org', 'password' => 'simple123'];
         $response = $this->post("api/auth/login", $post);
         $json = $response->json();
 
         $response->assertStatus(200);
         $this->assertTrue(!empty($json['access_token']));
         $this->assertTrue(
             ($this->expectedExpiry - 5) < $json['expires_in'] &&
             $json['expires_in'] < ($this->expectedExpiry + 5)
         );
         $this->assertEquals('bearer', $json['token_type']);
         $this->assertEquals($user->id, $json['id']);
         $this->assertEquals($user->email, $json['email']);
         $this->assertTrue(is_array($json['statusInfo']));
         $this->assertTrue(is_array($json['settings']));
 
         // Valid user+password (upper-case)
         $post = ['email' => 'John@Kolab.org', 'password' => 'simple123'];
         $response = $this->post("api/auth/login", $post);
         $json = $response->json();
 
         $response->assertStatus(200);
         $this->assertTrue(!empty($json['access_token']));
         $this->assertTrue(
             ($this->expectedExpiry - 5) < $json['expires_in'] &&
             $json['expires_in'] < ($this->expectedExpiry + 5)
         );
         $this->assertEquals('bearer', $json['token_type']);
 
         // TODO: We have browser tests for 2FA but we should probably also test it here
 
         return $json['access_token'];
     }
 
     /**
      * Test /api/auth/login with geo-lockin
      */
     public function testLoginGeoLock(): void
     {
         $user = $this->getTestUser('john@kolab.org');
         $user->setConfig(['limit_geo' => ['US']]);
 
         $headers['X-Client-IP'] = '127.0.0.2';
         $post = ['email' => 'john@kolab.org', 'password' => 'simple123'];
 
         $response = $this->withHeaders($headers)->post("api/auth/login", $post);
         $response->assertStatus(401);
 
         $json = $response->json();
 
         $this->assertSame("Invalid username or password.", $json['message']);
         $this->assertSame('error', $json['status']);
 
         \App\IP4Net::create([
                 'net_number' => '127.0.0.0',
                 'net_broadcast' => '127.255.255.255',
                 'net_mask' => 8,
                 'country' => 'US',
                 'rir_name' => 'test',
                 'serial' => 1,
         ]);
 
         $response = $this->withHeaders($headers)->post("api/auth/login", $post);
         $response->assertStatus(200);
 
         $json = $response->json();
 
         $this->assertTrue(!empty($json['access_token']));
         $this->assertEquals($user->id, $json['id']);
     }
 
     /**
      * Test /api/auth/logout
      *
      * @depends testLogin
      */
     public function testLogout($token): void
     {
         // Request with no token, testing that it requires auth
         $response = $this->post("api/auth/logout");
         $response->assertStatus(401);
 
         // Test the same using JSON mode
         $response = $this->json('POST', "api/auth/logout", []);
         $response->assertStatus(401);
 
         // Request with invalid token
         $response = $this->withHeaders(['Authorization' => 'Bearer ' . "foobar"])->post("api/auth/logout");
         $response->assertStatus(401);
 
         // Request with valid token
         $response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])->post("api/auth/logout");
         $response->assertStatus(200);
 
         $json = $response->json();
 
         $this->assertEquals('success', $json['status']);
         $this->assertEquals('Successfully logged out.', $json['message']);
         $this->resetAuth();
 
         // Check if it really destroyed the token?
         $response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])->get("api/auth/info");
         $response->assertStatus(401);
     }
 
     /**
      * Test /api/auth/refresh
      */
     public function testRefresh(): void
     {
         // Request with no token, testing that it requires auth
         $response = $this->post("api/auth/refresh");
         $response->assertStatus(401);
 
         // Test the same using JSON mode
         $response = $this->json('POST', "api/auth/refresh", []);
         $response->assertStatus(401);
 
         // Login the user to get a valid token
         $post = ['email' => 'john@kolab.org', 'password' => 'simple123'];
         $response = $this->post("api/auth/login", $post);
         $response->assertStatus(200);
         $json = $response->json();
         $token = $json['access_token'];
 
         $user = $this->getTestUser('john@kolab.org');
 
         // Request with a valid token
         $response = $this->actingAs($user)->post("api/auth/refresh", ['refresh_token' => $json['refresh_token']]);
         $response->assertStatus(200);
 
         $json = $response->json();
 
         $this->assertTrue(!empty($json['access_token']));
         $this->assertTrue($json['access_token'] != $token);
         $this->assertTrue(
             ($this->expectedExpiry - 5) < $json['expires_in'] &&
             $json['expires_in'] < ($this->expectedExpiry + 5)
         );
         $this->assertEquals('bearer', $json['token_type']);
         $new_token = $json['access_token'];
 
         // TODO: Shall we invalidate the old token?
 
         // And if the new token is working
         $response = $this->withHeaders(['Authorization' => 'Bearer ' . $new_token])->get("api/auth/info");
         $response->assertStatus(200);
     }
+
+    /**
+     * Test OAuth2 Authorization Code Flow
+     */
+    public function testOAuthAuthorizationCodeFlow(): void
+    {
+        $user = $this->getTestUser('john@kolab.org');
+
+        // Request unauthenticated, testing that it requires auth
+        $response = $this->post("api/oauth/approve");
+        $response->assertStatus(401);
+
+        // Request authenticated, invalid POST data
+        $post = ['response_type' => 'unknown'];
+        $response = $this->actingAs($user)->post("api/oauth/approve", $post);
+        $response->assertStatus(422);
+
+        $json = $response->json();
+
+        $this->assertSame('error', $json['status']);
+        $this->assertSame('Invalid value of request property: response_type.', $json['message']);
+
+        // Request authenticated, invalid POST data
+        $post = [
+            'client_id' => 'unknown',
+            'response_type' => 'code',
+            'scope' => 'email', // space-separated
+            'state' => 'state', // optional
+            'nonce' => 'nonce', // optional
+        ];
+        $response = $this->actingAs($user)->post("api/oauth/approve", $post);
+        $response->assertStatus(422);
+
+        $json = $response->json();
+
+        $this->assertSame('error', $json['status']);
+        $this->assertSame('Client authentication failed', $json['message']);
+
+        $client = \App\Auth\PassportClient::find(\config('auth.synapse.client_id'));
+
+        $post['client_id'] = $client->id;
+
+        // Request authenticated, invalid scope
+        $post['scope'] = 'unknown';
+        $response = $this->actingAs($user)->post("api/oauth/approve", $post);
+        $response->assertStatus(422);
+
+        $json = $response->json();
+
+        $this->assertSame('error', $json['status']);
+        $this->assertSame('The requested scope is invalid, unknown, or malformed', $json['message']);
+
+        // Request authenticated, valid POST data
+        $post['scope'] = 'email';
+        $response = $this->actingAs($user)->post("api/oauth/approve", $post);
+        $response->assertStatus(200);
+
+        $json = $response->json();
+
+        $url = $json['redirectUrl'];
+        parse_str(parse_url($url, \PHP_URL_QUERY), $params);
+
+        $this->assertTrue(str_starts_with($url, $client->redirect . '?'));
+        $this->assertCount(2, $params);
+        $this->assertSame('state', $params['state']);
+        $this->assertMatchesRegularExpression('/^[a-f0-9]{50,}$/', $params['code']);
+        $this->assertSame('success', $json['status']);
+
+        // Note: We do not validate the code trusting Passport to do the right thing. Should we not?
+
+        // Token endpoint tests
+
+        // Valid authorization code, but invalid secret
+        $post = [
+            'grant_type' => 'authorization_code',
+            'client_id' => $client->id,
+            'client_secret' => 'invalid',
+            // 'redirect_uri' => '',
+            'code' => $params['code'],
+        ];
+
+        // Note: This is a 'web' route, not 'api'
+        $this->resetAuth(); // reset guards
+        $response = $this->post("/oauth/token", $post);
+        $response->assertStatus(401);
+
+        $json = $response->json();
+
+        $this->assertSame('invalid_client', $json['error']);
+        $this->assertTrue(!empty($json['error_description']));
+
+        // Valid authorization code
+        $post['client_secret'] = \config('auth.synapse.client_secret');
+        $response = $this->post("/oauth/token", $post);
+        $response->assertStatus(200);
+
+        $params = $response->json();
+
+        $this->assertSame('Bearer', $params['token_type']);
+        $this->assertTrue(!empty($params['access_token']));
+        $this->assertTrue(!empty($params['refresh_token']));
+        $this->assertTrue(!empty($params['expires_in']));
+        $this->assertTrue(empty($params['id_token']));
+
+        // Invalid authorization code
+        // Note: The code is being revoked on use, so we expect it does not work anymore
+        $response = $this->post("/oauth/token", $post);
+        $response->assertStatus(400);
+
+        $json = $response->json();
+
+        $this->assertSame('invalid_request', $json['error']);
+        $this->assertTrue(!empty($json['error_description']));
+
+        // Token refresh
+        unset($post['code']);
+        $post['grant_type'] = 'refresh_token';
+        $post['refresh_token'] = $params['refresh_token'];
+
+        $response = $this->post("/oauth/token", $post);
+        $response->assertStatus(200);
+
+        $json = $response->json();
+
+        $this->assertSame('Bearer', $json['token_type']);
+        $this->assertTrue(!empty($json['access_token']));
+        $this->assertTrue(!empty($json['refresh_token']));
+        $this->assertTrue(!empty($json['expires_in']));
+        $this->assertTrue(empty($json['id_token']));
+        $this->assertNotEquals($json['access_token'], $params['access_token']);
+        $this->assertNotEquals($json['refresh_token'], $params['refresh_token']);
+
+        $token = $json['access_token'];
+
+        // Validate the access token works on /oauth/userinfo endpoint
+        $this->resetAuth(); // reset guards
+        $headers = ['Authorization' => 'Bearer ' . $token];
+        $response = $this->withHeaders($headers)->get("/oauth/userinfo");
+        $response->assertStatus(200);
+
+        $json = $response->json();
+
+        $this->assertEquals($user->id, $json['sub']);
+        $this->assertEquals($user->email, $json['email']);
+
+        // Validate that the access token does not give access to API other than /oauth/userinfo
+        $this->resetAuth(); // reset guards
+        $response = $this->withHeaders($headers)->get("/api/auth/location");
+        $response->assertStatus(403);
+    }
+
+    /**
+     * Test OpenID-Connect Authorization Code Flow
+     */
+    public function testOIDCAuthorizationCodeFlow(): void
+    {
+        $user = $this->getTestUser('john@kolab.org');
+        $client = \App\Auth\PassportClient::find(\config('auth.synapse.client_id'));
+
+        // Note: Invalid input cases were tested above, we omit them here
+
+        // This is essentially the same as for OAuth2, but with extended scope
+        $post = [
+            'client_id' => $client->id,
+            'response_type' => 'code',
+            'scope' => 'openid email',
+            'state' => 'state',
+            'nonce' => 'nonce',
+        ];
+
+        $response = $this->actingAs($user)->post("api/oauth/approve", $post);
+        $response->assertStatus(200);
+
+        $json = $response->json();
+
+        $url = $json['redirectUrl'];
+        parse_str(parse_url($url, \PHP_URL_QUERY), $params);
+
+        $this->assertTrue(str_starts_with($url, $client->redirect . '?'));
+        $this->assertCount(2, $params);
+        $this->assertSame('state', $params['state']);
+        $this->assertMatchesRegularExpression('/^[a-f0-9]{50,}$/', $params['code']);
+        $this->assertSame('success', $json['status']);
+
+        // Token endpoint tests
+        $post = [
+            'grant_type' => 'authorization_code',
+            'client_id' => $client->id,
+            'client_secret' => \config('auth.synapse.client_secret'),
+            'code' => $params['code'],
+        ];
+
+        $this->resetAuth(); // reset guards state
+        $response = $this->post("/oauth/token", $post);
+        $response->assertStatus(200);
+
+        $params = $response->json();
+
+        $this->assertSame('Bearer', $params['token_type']);
+        $this->assertTrue(!empty($params['access_token']));
+        $this->assertTrue(!empty($params['refresh_token']));
+        $this->assertTrue(!empty($params['id_token']));
+        $this->assertTrue(!empty($params['expires_in']));
+
+        $token = $this->parseIdToken($params['id_token']);
+
+        $this->assertSame('JWT', $token['typ']);
+        $this->assertSame('RS256', $token['alg']);
+        $this->assertSame(url('/'), $token['iss']);
+        $this->assertSame($user->email, $token['email']);
+
+        // TODO: Validate JWT token properly
+
+        // Token refresh
+        unset($post['code']);
+        $post['grant_type'] = 'refresh_token';
+        $post['refresh_token'] = $params['refresh_token'];
+
+        $response = $this->post("/oauth/token", $post);
+        $response->assertStatus(200);
+
+        $json = $response->json();
+
+        $this->assertSame('Bearer', $json['token_type']);
+        $this->assertTrue(!empty($json['access_token']));
+        $this->assertTrue(!empty($json['refresh_token']));
+        $this->assertTrue(!empty($json['id_token']));
+        $this->assertTrue(!empty($json['expires_in']));
+
+        // Validate the access token works on /oauth/userinfo endpoint
+        $this->resetAuth(); // reset guards state
+        $headers = ['Authorization' => 'Bearer ' . $json['access_token']];
+        $response = $this->withHeaders($headers)->get("/oauth/userinfo");
+        $response->assertStatus(200);
+
+        $json = $response->json();
+
+        $this->assertEquals($user->id, $json['sub']);
+        $this->assertEquals($user->email, $json['email']);
+
+        // Validate that the access token does not give access to API other than /oauth/userinfo
+        $this->resetAuth(); // reset guards state
+        $response = $this->withHeaders($headers)->get("/api/auth/location");
+        $response->assertStatus(403);
+    }
+
+    /**
+     * Test to make sure Passport routes are disabled
+     */
+    public function testPassportDisabledRoutes(): void
+    {
+        $this->post("/oauth/authorize", [])->assertStatus(405);
+        $this->post("/oauth/token/refresh", [])->assertStatus(405);
+    }
+
+    /**
+     * Parse JWT token into an array
+     */
+    private function parseIdToken($token): array
+    {
+        [$headb64, $bodyb64, $cryptob64] = explode('.', $token);
+
+        $header = json_decode(base64_decode(strtr($headb64, '-_', '+/'), true), true);
+        $body = json_decode(base64_decode(strtr($bodyb64, '-_', '+/'), true), true);
+
+        return array_merge($header, $body);
+    }
 }
diff --git a/src/tests/Feature/Controller/WellKnownTest.php b/src/tests/Feature/Controller/WellKnownTest.php
new file mode 100644
index 00000000..f8a384e2
--- /dev/null
+++ b/src/tests/Feature/Controller/WellKnownTest.php
@@ -0,0 +1,54 @@
+<?php
+
+namespace Tests\Feature\Controller;
+
+use Tests\TestCase;
+
+class WellKnownTest extends TestCase
+{
+    /**
+     * Test ./well-known/openid-configuration
+     */
+    public function testOpenidConfiguration(): void
+    {
+        $href = 'https://' . \config('app.domain');
+
+        $response = $this->get('.well-known/openid-configuration');
+        $response->assertStatus(200)
+            ->assertJson([
+                'issuer' => $href,
+                'authorization_endpoint' => $href . '/oauth/authorize',
+                'token_endpoint' => $href . '/oauth/token',
+                'userinfo_endpoint' => $href . '/oauth/userinfo',
+                'grant_types_supported' => [
+                    'authorization_code',
+                    'client_credentials',
+                    'refresh_token',
+                    'password',
+                ],
+                'response_types_supported' => [
+                    'code'
+                ],
+                'id_token_signing_alg_values_supported' => [
+                    'RS256'
+                ],
+                'scopes_supported' => [
+                    'openid',
+                    'email',
+                ],
+            ]);
+    }
+
+    /**
+     * Test ./well-known/mta-sts.txt
+     */
+    public function testMtaSts(): void
+    {
+        $domain = \config('app.domain');
+
+        $response = $this->get('.well-known/mta-sts.txt');
+        $response->assertStatus(200)
+            ->assertHeader('Content-Type', 'text/plain; charset=UTF-8')
+            ->assertContent("version: STSv1\nmode: enforce\nmx: {$domain}\nmax_age: 604800");
+    }
+}