From f246792965d2366d325bf1b555cddea4d942b9a7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 12:03:04 +0000 Subject: [PATCH 01/31] Translations updates --- frontend/.storybook/locales.ts | 78 +++++++++++++++++----------------- frontend/locales/fi.json | 20 ++++----- frontend/locales/fr.json | 2 +- frontend/locales/hu.json | 4 +- 4 files changed, 52 insertions(+), 52 deletions(-) diff --git a/frontend/.storybook/locales.ts b/frontend/.storybook/locales.ts index f71735058..ffcc627ed 100644 --- a/frontend/.storybook/locales.ts +++ b/frontend/.storybook/locales.ts @@ -27,7 +27,7 @@ export type LocalazyMetadata = { }; const localazyMetadata: LocalazyMetadata = { - projectUrl: "https://localazy.com/p/matrix-authentication-service!v1.15", + projectUrl: "https://localazy.com/p/matrix-authentication-service", baseLocale: "en", languages: [ { @@ -208,25 +208,25 @@ const localazyMetadata: LocalazyMetadata = { file: "frontend.json", path: "", cdnFiles: { - "cs": "https://delivery.localazy.com/_a6577655471040064500233bbe56/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/cs/frontend.json", - "da": "https://delivery.localazy.com/_a6577655471040064500233bbe56/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/da/frontend.json", - "de": "https://delivery.localazy.com/_a6577655471040064500233bbe56/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/de/frontend.json", - "en": "https://delivery.localazy.com/_a6577655471040064500233bbe56/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/en/frontend.json", - "et": "https://delivery.localazy.com/_a6577655471040064500233bbe56/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/et/frontend.json", - "fi": "https://delivery.localazy.com/_a6577655471040064500233bbe56/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/fi/frontend.json", - "fr": "https://delivery.localazy.com/_a6577655471040064500233bbe56/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/fr/frontend.json", - "hu": "https://delivery.localazy.com/_a6577655471040064500233bbe56/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/hu/frontend.json", - "nb_NO": "https://delivery.localazy.com/_a6577655471040064500233bbe56/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/nb-NO/frontend.json", - "nl": "https://delivery.localazy.com/_a6577655471040064500233bbe56/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/nl/frontend.json", - "pl": "https://delivery.localazy.com/_a6577655471040064500233bbe56/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pl/frontend.json", - "pt": "https://delivery.localazy.com/_a6577655471040064500233bbe56/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pt/frontend.json", - "pt_BR": "https://delivery.localazy.com/_a6577655471040064500233bbe56/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pt-BR/frontend.json", - "ru": "https://delivery.localazy.com/_a6577655471040064500233bbe56/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/ru/frontend.json", - "sk": "https://delivery.localazy.com/_a6577655471040064500233bbe56/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/sk/frontend.json", - "sv": "https://delivery.localazy.com/_a6577655471040064500233bbe56/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/sv/frontend.json", - "uk": "https://delivery.localazy.com/_a6577655471040064500233bbe56/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/uk/frontend.json", - "uz": "https://delivery.localazy.com/_a6577655471040064500233bbe56/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/uz/frontend.json", - "zh#Hans": "https://delivery.localazy.com/_a6577655471040064500233bbe56/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/zh-Hans/frontend.json" + "cs": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/cs/frontend.json", + "da": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/da/frontend.json", + "de": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/de/frontend.json", + "en": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/en/frontend.json", + "et": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/et/frontend.json", + "fi": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/fi/frontend.json", + "fr": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/fr/frontend.json", + "hu": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/hu/frontend.json", + "nb_NO": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/nb-NO/frontend.json", + "nl": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/nl/frontend.json", + "pl": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pl/frontend.json", + "pt": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pt/frontend.json", + "pt_BR": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pt-BR/frontend.json", + "ru": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/ru/frontend.json", + "sk": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/sk/frontend.json", + "sv": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/sv/frontend.json", + "uk": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/uk/frontend.json", + "uz": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/uz/frontend.json", + "zh#Hans": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/zh-Hans/frontend.json" } }, { @@ -234,25 +234,25 @@ const localazyMetadata: LocalazyMetadata = { file: "file.json", path: "", cdnFiles: { - "cs": "https://delivery.localazy.com/_a6577655471040064500233bbe56/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/cs/file.json", - "da": "https://delivery.localazy.com/_a6577655471040064500233bbe56/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/da/file.json", - "de": "https://delivery.localazy.com/_a6577655471040064500233bbe56/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/de/file.json", - "en": "https://delivery.localazy.com/_a6577655471040064500233bbe56/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/en/file.json", - "et": "https://delivery.localazy.com/_a6577655471040064500233bbe56/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/et/file.json", - "fi": "https://delivery.localazy.com/_a6577655471040064500233bbe56/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/fi/file.json", - "fr": "https://delivery.localazy.com/_a6577655471040064500233bbe56/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/fr/file.json", - "hu": "https://delivery.localazy.com/_a6577655471040064500233bbe56/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/hu/file.json", - "nb_NO": "https://delivery.localazy.com/_a6577655471040064500233bbe56/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/nb-NO/file.json", - "nl": "https://delivery.localazy.com/_a6577655471040064500233bbe56/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/nl/file.json", - "pl": "https://delivery.localazy.com/_a6577655471040064500233bbe56/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pl/file.json", - "pt": "https://delivery.localazy.com/_a6577655471040064500233bbe56/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pt/file.json", - "pt_BR": "https://delivery.localazy.com/_a6577655471040064500233bbe56/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pt-BR/file.json", - "ru": "https://delivery.localazy.com/_a6577655471040064500233bbe56/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/ru/file.json", - "sk": "https://delivery.localazy.com/_a6577655471040064500233bbe56/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/sk/file.json", - "sv": "https://delivery.localazy.com/_a6577655471040064500233bbe56/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/sv/file.json", - "uk": "https://delivery.localazy.com/_a6577655471040064500233bbe56/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/uk/file.json", - "uz": "https://delivery.localazy.com/_a6577655471040064500233bbe56/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/uz/file.json", - "zh#Hans": "https://delivery.localazy.com/_a6577655471040064500233bbe56/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/zh-Hans/file.json" + "cs": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/cs/file.json", + "da": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/da/file.json", + "de": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/de/file.json", + "en": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/en/file.json", + "et": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/et/file.json", + "fi": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/fi/file.json", + "fr": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/fr/file.json", + "hu": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/hu/file.json", + "nb_NO": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/nb-NO/file.json", + "nl": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/nl/file.json", + "pl": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pl/file.json", + "pt": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pt/file.json", + "pt_BR": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pt-BR/file.json", + "ru": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/ru/file.json", + "sk": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/sk/file.json", + "sv": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/sv/file.json", + "uk": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/uk/file.json", + "uz": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/uz/file.json", + "zh#Hans": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/zh-Hans/file.json" } } ] diff --git a/frontend/locales/fi.json b/frontend/locales/fi.json index ed3574a94..4283fd13f 100644 --- a/frontend/locales/fi.json +++ b/frontend/locales/fi.json @@ -258,10 +258,10 @@ "button": "Nollaa identiteetti", "cancelled": { "description_1": "Voit sulkea tämän ikkunan ja palata sovellukseen jatkaaksesi.", - "description_2": "Jos olet kirjautunut ulos kaikkialta etkä muista palautuskoodiasi, sinun on silti nollattava identiteettisi.", - "heading": "Identiteetin nollaus peruutettu." + "description_2": "Jos sinulla ei ole muita vahvistettuja laitteita eikä palautuskoodiasi käytettävissä, sinun on nollattava digitaalinen identiteettisi, jotta voit jatkaa sovelluksen käyttöä.", + "heading": "Digitaalisen identiteetin nollaus peruutettu." }, - "description": "Jos et ole kirjautunut muihin laitteisiin ja olet kadottanut palautusavaimesi, sinun on nollattava identiteettisi, jotta voit jatkaa sovelluksen käyttöä.", + "description": "Jos sinulla ei ole muita vahvistettuja laitteita eikä palautuskoodiasi käytettävissä, sinun on nollattava digitaalinen identiteettisi, jotta voit jatkaa sovelluksen käyttöä.", "effect_list": { "negative_1": "Menetät nykyisen viestihistoriasi", "negative_2": "Sinun on vahvistettava kaikki olemassa olevat laitteesi ja yhteystietosi uudelleen", @@ -271,18 +271,18 @@ }, "failure": { "description": "Tämä saattaa olla väliaikainen ongelma, joten yritä myöhemmin uudelleen. Jos ongelma jatkuu, ota yhteyttä palvelimen ylläpitäjään.", - "heading": "Kryptografisen identiteetin nollauksen salliminen epäonnistui", - "title": "Kryptografisen identiteetin nollauksen salliminen epäonnistui" + "heading": "Digitaalisen identiteetin nollauksen salliminen epäonnistui", + "title": "Digitaalisen identiteetin nollauksen salliminen epäonnistui" }, "finish_reset": "Viimeistele nollaus", - "heading": "Nollaa identiteettisi, jos et voi vahvistaa muulla tavalla", + "heading": "Nollaa digitaalinen identiteettisi, jos et voi vahvistaa muulla tavalla", "start_reset": "Aloita nollaus", "success": { - "description": "Identiteetin nollaus on hyväksytty seuraavaksi {{minutes}} minuutiksi. Voit sulkea tämän ikkunan ja palata sovellukseen jatkaaksesi.", - "heading": "Identiteetin nollaus onnistui. Palaa takaisin sovellukseen viimeistelläksesi prosessin.", - "title": "Kryptografisen identiteetin nollaus tilapäisesti sallittu" + "description": "Digitaalisen identiteetin nollaus on hyväksytty seuraavaksi {{minutes}} minuutiksi. Voit sulkea tämän ikkunan ja palata sovellukseen jatkaaksesi.", + "heading": "Digitaalisen identiteetin nollaus onnistui. Palaa takaisin sovellukseen viimeistelläksesi prosessin.", + "title": "Digitaalisen identiteetin nollaus tilapäisesti sallittu" }, - "warning": "Nollaa identiteettisi vain, jos et voi käyttää toista laitetta, johon olet kirjautunut, ja olet kadottanut palautusavaimesi." + "warning": "Nollaa digitaalinen identiteettisi vain, jos sinulla ei ole toista vahvistettua laitetta tai palautusavaintasi käytettävissä." }, "selectable_session": { "label": "Valitse istunto" diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 1df554c0f..9f2ddfeb3 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -125,7 +125,7 @@ "heading": "L’adresse e-mail {{email}} est déjà utilisée." }, "end_session_button": { - "confirmation_modal_body_text": "Assurez-vous de toujours avoir accès à un autre appareil vérifié ou à votre clé de récupération afin d'éviter de perdre l'historique de vos discussions chiffrées.", + "confirmation_modal_body_text": "Make sure you always have access to another verified device or your recovery key to avoid losing your encrypted chat history.", "confirmation_modal_title": "Êtes-vous sûr de vouloir terminer cette session ?", "text": "Supprimer l’appareil" }, diff --git a/frontend/locales/hu.json b/frontend/locales/hu.json index 010191754..73949f28d 100644 --- a/frontend/locales/hu.json +++ b/frontend/locales/hu.json @@ -125,8 +125,8 @@ "heading": "A(z) {{email}} e-mail-cím már használatban van." }, "end_session_button": { - "confirmation_modal_body_text": "Make sure you always have access to another verified device or your recovery key to avoid losing your encrypted chat history.", - "confirmation_modal_title": "Biztos, hogy befejezi a munkamenetet?", + "confirmation_modal_body_text": "Győződjön meg róla, hogy mindig hozzáfér egy másik ellenőrzött eszközhöz vagy a helyreállítási kulcsához, hogy elkerülje a titkosított csevegési előzmények elvesztését.", + "confirmation_modal_title": "Biztos, hogy eltávolítja ezt az eszközt?", "text": "Eszköz eltávolítása" }, "error": { From ba306d4bcbc81120d21bc4f4692f0469b51ddb53 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 12:23:53 +0000 Subject: [PATCH 02/31] 1.16.0-rc.0 --- Cargo.lock | 56 +++++++++++++++++++++++++------------------------- Cargo.toml | 60 +++++++++++++++++++++++++++--------------------------- 2 files changed, 58 insertions(+), 58 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 30664b95c..39a054965 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3098,7 +3098,7 @@ dependencies = [ [[package]] name = "mas-axum-utils" -version = "1.15.0" +version = "1.16.0-rc.0" dependencies = [ "anyhow", "axum", @@ -3132,7 +3132,7 @@ dependencies = [ [[package]] name = "mas-cli" -version = "1.15.0" +version = "1.16.0-rc.0" dependencies = [ "anyhow", "axum", @@ -3207,7 +3207,7 @@ dependencies = [ [[package]] name = "mas-config" -version = "1.15.0" +version = "1.16.0-rc.0" dependencies = [ "anyhow", "camino", @@ -3238,7 +3238,7 @@ dependencies = [ [[package]] name = "mas-context" -version = "1.15.0" +version = "1.16.0-rc.0" dependencies = [ "console", "opentelemetry", @@ -3254,7 +3254,7 @@ dependencies = [ [[package]] name = "mas-data-model" -version = "1.15.0" +version = "1.16.0-rc.0" dependencies = [ "base64ct", "chrono", @@ -3275,7 +3275,7 @@ dependencies = [ [[package]] name = "mas-email" -version = "1.15.0" +version = "1.16.0-rc.0" dependencies = [ "async-trait", "lettre", @@ -3286,7 +3286,7 @@ dependencies = [ [[package]] name = "mas-handlers" -version = "1.15.0" +version = "1.16.0-rc.0" dependencies = [ "aide", "anyhow", @@ -3367,7 +3367,7 @@ dependencies = [ [[package]] name = "mas-http" -version = "1.15.0" +version = "1.16.0-rc.0" dependencies = [ "futures-util", "headers", @@ -3388,7 +3388,7 @@ dependencies = [ [[package]] name = "mas-i18n" -version = "1.15.0" +version = "1.16.0-rc.0" dependencies = [ "camino", "icu_calendar", @@ -3410,7 +3410,7 @@ dependencies = [ [[package]] name = "mas-i18n-scan" -version = "1.15.0" +version = "1.16.0-rc.0" dependencies = [ "camino", "clap", @@ -3424,7 +3424,7 @@ dependencies = [ [[package]] name = "mas-iana" -version = "1.15.0" +version = "1.16.0-rc.0" dependencies = [ "schemars 0.9.0", "serde", @@ -3432,7 +3432,7 @@ dependencies = [ [[package]] name = "mas-iana-codegen" -version = "1.15.0" +version = "1.16.0-rc.0" dependencies = [ "anyhow", "async-trait", @@ -3449,7 +3449,7 @@ dependencies = [ [[package]] name = "mas-jose" -version = "1.15.0" +version = "1.16.0-rc.0" dependencies = [ "base64ct", "chrono", @@ -3479,7 +3479,7 @@ dependencies = [ [[package]] name = "mas-keystore" -version = "1.15.0" +version = "1.16.0-rc.0" dependencies = [ "aead", "base64ct", @@ -3507,7 +3507,7 @@ dependencies = [ [[package]] name = "mas-listener" -version = "1.15.0" +version = "1.16.0-rc.0" dependencies = [ "anyhow", "bytes", @@ -3531,7 +3531,7 @@ dependencies = [ [[package]] name = "mas-matrix" -version = "1.15.0" +version = "1.16.0-rc.0" dependencies = [ "anyhow", "async-trait", @@ -3541,7 +3541,7 @@ dependencies = [ [[package]] name = "mas-matrix-synapse" -version = "1.15.0" +version = "1.16.0-rc.0" dependencies = [ "anyhow", "async-trait", @@ -3558,7 +3558,7 @@ dependencies = [ [[package]] name = "mas-oidc-client" -version = "1.15.0" +version = "1.16.0-rc.0" dependencies = [ "assert_matches", "async-trait", @@ -3594,7 +3594,7 @@ dependencies = [ [[package]] name = "mas-policy" -version = "1.15.0" +version = "1.16.0-rc.0" dependencies = [ "anyhow", "arc-swap", @@ -3611,7 +3611,7 @@ dependencies = [ [[package]] name = "mas-router" -version = "1.15.0" +version = "1.16.0-rc.0" dependencies = [ "axum", "serde", @@ -3622,7 +3622,7 @@ dependencies = [ [[package]] name = "mas-spa" -version = "1.15.0" +version = "1.16.0-rc.0" dependencies = [ "camino", "serde", @@ -3631,7 +3631,7 @@ dependencies = [ [[package]] name = "mas-storage" -version = "1.15.0" +version = "1.16.0-rc.0" dependencies = [ "async-trait", "chrono", @@ -3653,7 +3653,7 @@ dependencies = [ [[package]] name = "mas-storage-pg" -version = "1.15.0" +version = "1.16.0-rc.0" dependencies = [ "async-trait", "chrono", @@ -3683,7 +3683,7 @@ dependencies = [ [[package]] name = "mas-tasks" -version = "1.15.0" +version = "1.16.0-rc.0" dependencies = [ "anyhow", "async-trait", @@ -3715,7 +3715,7 @@ dependencies = [ [[package]] name = "mas-templates" -version = "1.15.0" +version = "1.16.0-rc.0" dependencies = [ "anyhow", "arc-swap", @@ -3747,7 +3747,7 @@ dependencies = [ [[package]] name = "mas-tower" -version = "1.15.0" +version = "1.16.0-rc.0" dependencies = [ "http", "opentelemetry", @@ -4000,7 +4000,7 @@ dependencies = [ [[package]] name = "oauth2-types" -version = "1.15.0" +version = "1.16.0-rc.0" dependencies = [ "assert_matches", "base64ct", @@ -6105,7 +6105,7 @@ dependencies = [ [[package]] name = "syn2mas" -version = "1.15.0" +version = "1.16.0-rc.0" dependencies = [ "anyhow", "arc-swap", diff --git a/Cargo.toml b/Cargo.toml index cb396ec4b..37b542a8e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ members = ["crates/*"] resolver = "2" # Updated in the CI with a `sed` command -package.version = "1.15.0" +package.version = "1.16.0-rc.0" package.license = "AGPL-3.0-only OR LicenseRef-Element-Commercial" package.authors = ["Element Backend Team"] package.edition = "2024" @@ -34,35 +34,35 @@ broken_intra_doc_links = "deny" [workspace.dependencies] # Workspace crates -mas-axum-utils = { path = "./crates/axum-utils/", version = "=1.15.0" } -mas-cli = { path = "./crates/cli/", version = "=1.15.0" } -mas-config = { path = "./crates/config/", version = "=1.15.0" } -mas-context = { path = "./crates/context/", version = "=1.15.0" } -mas-data-model = { path = "./crates/data-model/", version = "=1.15.0" } -mas-email = { path = "./crates/email/", version = "=1.15.0" } -mas-graphql = { path = "./crates/graphql/", version = "=1.15.0" } -mas-handlers = { path = "./crates/handlers/", version = "=1.15.0" } -mas-http = { path = "./crates/http/", version = "=1.15.0" } -mas-i18n = { path = "./crates/i18n/", version = "=1.15.0" } -mas-i18n-scan = { path = "./crates/i18n-scan/", version = "=1.15.0" } -mas-iana = { path = "./crates/iana/", version = "=1.15.0" } -mas-iana-codegen = { path = "./crates/iana-codegen/", version = "=1.15.0" } -mas-jose = { path = "./crates/jose/", version = "=1.15.0" } -mas-keystore = { path = "./crates/keystore/", version = "=1.15.0" } -mas-listener = { path = "./crates/listener/", version = "=1.15.0" } -mas-matrix = { path = "./crates/matrix/", version = "=1.15.0" } -mas-matrix-synapse = { path = "./crates/matrix-synapse/", version = "=1.15.0" } -mas-oidc-client = { path = "./crates/oidc-client/", version = "=1.15.0" } -mas-policy = { path = "./crates/policy/", version = "=1.15.0" } -mas-router = { path = "./crates/router/", version = "=1.15.0" } -mas-spa = { path = "./crates/spa/", version = "=1.15.0" } -mas-storage = { path = "./crates/storage/", version = "=1.15.0" } -mas-storage-pg = { path = "./crates/storage-pg/", version = "=1.15.0" } -mas-tasks = { path = "./crates/tasks/", version = "=1.15.0" } -mas-templates = { path = "./crates/templates/", version = "=1.15.0" } -mas-tower = { path = "./crates/tower/", version = "=1.15.0" } -oauth2-types = { path = "./crates/oauth2-types/", version = "=1.15.0" } -syn2mas = { path = "./crates/syn2mas", version = "=1.15.0" } +mas-axum-utils = { path = "./crates/axum-utils/", version = "=1.16.0-rc.0" } +mas-cli = { path = "./crates/cli/", version = "=1.16.0-rc.0" } +mas-config = { path = "./crates/config/", version = "=1.16.0-rc.0" } +mas-context = { path = "./crates/context/", version = "=1.16.0-rc.0" } +mas-data-model = { path = "./crates/data-model/", version = "=1.16.0-rc.0" } +mas-email = { path = "./crates/email/", version = "=1.16.0-rc.0" } +mas-graphql = { path = "./crates/graphql/", version = "=1.16.0-rc.0" } +mas-handlers = { path = "./crates/handlers/", version = "=1.16.0-rc.0" } +mas-http = { path = "./crates/http/", version = "=1.16.0-rc.0" } +mas-i18n = { path = "./crates/i18n/", version = "=1.16.0-rc.0" } +mas-i18n-scan = { path = "./crates/i18n-scan/", version = "=1.16.0-rc.0" } +mas-iana = { path = "./crates/iana/", version = "=1.16.0-rc.0" } +mas-iana-codegen = { path = "./crates/iana-codegen/", version = "=1.16.0-rc.0" } +mas-jose = { path = "./crates/jose/", version = "=1.16.0-rc.0" } +mas-keystore = { path = "./crates/keystore/", version = "=1.16.0-rc.0" } +mas-listener = { path = "./crates/listener/", version = "=1.16.0-rc.0" } +mas-matrix = { path = "./crates/matrix/", version = "=1.16.0-rc.0" } +mas-matrix-synapse = { path = "./crates/matrix-synapse/", version = "=1.16.0-rc.0" } +mas-oidc-client = { path = "./crates/oidc-client/", version = "=1.16.0-rc.0" } +mas-policy = { path = "./crates/policy/", version = "=1.16.0-rc.0" } +mas-router = { path = "./crates/router/", version = "=1.16.0-rc.0" } +mas-spa = { path = "./crates/spa/", version = "=1.16.0-rc.0" } +mas-storage = { path = "./crates/storage/", version = "=1.16.0-rc.0" } +mas-storage-pg = { path = "./crates/storage-pg/", version = "=1.16.0-rc.0" } +mas-tasks = { path = "./crates/tasks/", version = "=1.16.0-rc.0" } +mas-templates = { path = "./crates/templates/", version = "=1.16.0-rc.0" } +mas-tower = { path = "./crates/tower/", version = "=1.16.0-rc.0" } +oauth2-types = { path = "./crates/oauth2-types/", version = "=1.16.0-rc.0" } +syn2mas = { path = "./crates/syn2mas", version = "=1.16.0-rc.0" } # OpenAPI schema generation and validation [workspace.dependencies.aide] From 777f74be5d7fb36d56a53a4e42cbe9f5d8049a58 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Tue, 21 Apr 2026 15:21:24 -0500 Subject: [PATCH 03/31] Clarify 90d inactive threshold See: - https://github.com/element-hq/matrix-authentication-service/pull/5607#discussion_r3119473863 - https://github.com/element-hq/matrix-authentication-service/pull/5607#discussion_r3119473783 --- crates/handlers/src/compat/login.rs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 2d3d204d5..803a401f0 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -544,18 +544,19 @@ async fn process_violations_for_compat_login( let lru_compat_sessions = { // TODO: In the future, instead of all of this faff, we can simply order // by `last_active_at` - // - // XXX: Since we can't order by `last_active_at` yet, we instead filter - // the list down to "inactive" sessions (`last_active_at` > 90 days - // ago). And by the nature of - // [`mas_data_model::compat::CompatSession::id`] being a `Ulid`/`Uuid` - // (the query is ordered by `compat_session_id`), the first bytes are a - // timestamp so we'll be getting the 'oldest created' sessions which is - // another good proxy. let mut edges_to_consider = Vec::new(); // First, find the "inactive" sessions + // + // XXX: Since we can't order by `last_active_at` yet, we instead + // filter the list down to "inactive" sessions (`last_active_at` > + // 90 days ago) (this matches the `getNinetyDaysAgo()` used in the + // web UI for "inactive" sessions). And by the nature of + // [`mas_data_model::compat::CompatSession::id`] being a + // `Ulid` (the query is ordered by `compat_session_id`), the + // first bytes are a timestamp so we'll be getting the 'oldest + // created' sessions which is another good proxy. let inactive_threshold_date = clock.now() - Duration::days(90); let inactive_compat_session_page = repo .compat_session() From 49dea7ee6115dbbffb0d71c00e6d79f75cbf86b8 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Tue, 21 Apr 2026 15:23:12 -0500 Subject: [PATCH 04/31] No hypen grammar See https://github.com/element-hq/matrix-authentication-service/pull/5607#discussion_r3119473792 --- crates/config/src/sections/experimental.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/config/src/sections/experimental.rs b/crates/config/src/sections/experimental.rs index a7165eed9..2df6cd660 100644 --- a/crates/config/src/sections/experimental.rs +++ b/crates/config/src/sections/experimental.rs @@ -195,7 +195,7 @@ impl SessionLimitConfig { // See [`SessionLimitConfig::hard_limit_eviction`] docstring if self.hard_limit_eviction && self.hard_limit.get() < 2 { return Err(figment::error::Error::from( - "Session `hard_limit` must be at-least 2 when automatic `hard_limit_eviction` is set. \ + "Session `hard_limit` must be at least 2 when automatic `hard_limit_eviction` is set. \ See configuration docs for more info.", ).with_path("hard_limit").into()); } From 8cd3b451becd0bfc550a27998eda4c56d047041c Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Tue, 21 Apr 2026 15:23:54 -0500 Subject: [PATCH 05/31] `catastrophically` typo See https://github.com/element-hq/matrix-authentication-service/pull/5607#discussion_r3119473807 --- crates/config/src/sections/experimental.rs | 2 +- docs/config.schema.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/config/src/sections/experimental.rs b/crates/config/src/sections/experimental.rs index 2df6cd660..d9326be63 100644 --- a/crates/config/src/sections/experimental.rs +++ b/crates/config/src/sections/experimental.rs @@ -173,7 +173,7 @@ pub struct SessionLimitConfig { /// recovery key setup. /// /// When using [`hard_limit_eviction`], the [`hard_limit`] must be - /// at-least 2 to avoid catastropically losing encrypted history and digital + /// at-least 2 to avoid catastrophically losing encrypted history and digital /// identity in pathological cases. Keep in mind this is a bare minimum /// restriction and you can still run into trouble. /// diff --git a/docs/config.schema.json b/docs/config.schema.json index 042bbafbd..369be92ba 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -2910,7 +2910,7 @@ "minimum": 1 }, "hard_limit_eviction": { - "description": "Whether we should automatically choose the least recently used devices\n to remove when the [`Self::hard_limit`] is reached; in order to\n allow the new login to continue.\n\n Disabled by default\n\n WARNING: Removing sessions is a potentially damaging operation. Any\n end-to-end encrypted history on the device will be lost and can only\n be recovered if you have another verified active device or have a\n recovery key setup.\n\n When using [`hard_limit_eviction`], the [`hard_limit`] must be\n at-least 2 to avoid catastropically losing encrypted history and digital\n identity in pathological cases. Keep in mind this is a bare minimum\n restriction and you can still run into trouble.\n\n This is most applicable in scenarios where your homeserver has many\n legacy bots/scripts that login over and over (which ideally should\n be using [personal access\n tokens](https://github.com/element-hq/matrix-authentication-service/issues/4492))\n and you want to avoid breaking their operation while maintaining some\n level of sanity with the number of devices that people can have.\n\n [`hard_limit`]: Self::hard_limit\n [`hard_limit_eviction`]: Self::hard_limit_eviction", + "description": "Whether we should automatically choose the least recently used devices\n to remove when the [`Self::hard_limit`] is reached; in order to\n allow the new login to continue.\n\n Disabled by default\n\n WARNING: Removing sessions is a potentially damaging operation. Any\n end-to-end encrypted history on the device will be lost and can only\n be recovered if you have another verified active device or have a\n recovery key setup.\n\n When using [`hard_limit_eviction`], the [`hard_limit`] must be\n at-least 2 to avoid catastrophically losing encrypted history and digital\n identity in pathological cases. Keep in mind this is a bare minimum\n restriction and you can still run into trouble.\n\n This is most applicable in scenarios where your homeserver has many\n legacy bots/scripts that login over and over (which ideally should\n be using [personal access\n tokens](https://github.com/element-hq/matrix-authentication-service/issues/4492))\n and you want to avoid breaking their operation while maintaining some\n level of sanity with the number of devices that people can have.\n\n [`hard_limit`]: Self::hard_limit\n [`hard_limit_eviction`]: Self::hard_limit_eviction", "type": "boolean", "default": false } @@ -2921,4 +2921,4 @@ ] } } -} \ No newline at end of file +} From 82376b5c06b82b6602606d4daa593c21f2e817bd Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Tue, 21 Apr 2026 15:36:23 -0500 Subject: [PATCH 06/31] Placeholder syntax See https://github.com/element-hq/matrix-authentication-service/pull/5607#discussion_r3119473813 --- crates/policy/src/model.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/policy/src/model.rs b/crates/policy/src/model.rs index d5ad5b632..479b8651a 100644 --- a/crates/policy/src/model.rs +++ b/crates/policy/src/model.rs @@ -73,7 +73,7 @@ impl ViolationVariant { Self::EmailDomainBanned => "email-domain-banned", Self::EmailNotAllowed => "email-not-allowed", Self::EmailBanned => "email-banned", - Self::TooManySessions { need_to_remove: _ } => "too-many-sessions", + Self::TooManySessions { .. } => "too-many-sessions", } } } From 5458ef9b82b6f1dd14f58d200309a25203a45f6f Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Tue, 21 Apr 2026 15:37:12 -0500 Subject: [PATCH 07/31] Expand `need_to_remove` docstring to explain what for See https://github.com/element-hq/matrix-authentication-service/pull/5607#discussion_r3119473819 --- crates/policy/src/model.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/policy/src/model.rs b/crates/policy/src/model.rs index 479b8651a..83ceb08b9 100644 --- a/crates/policy/src/model.rs +++ b/crates/policy/src/model.rs @@ -53,7 +53,7 @@ pub enum ViolationVariant { /// The user has reached their session limit. TooManySessions { - /// How many devices to remove + /// How many devices need to be removed to make room for the new session need_to_remove: u32, }, } From a92f040da23f15c8c4740fdcaddb5aab55dfdb80 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Tue, 21 Apr 2026 15:51:18 -0500 Subject: [PATCH 08/31] Log removed session ID's See https://github.com/element-hq/matrix-authentication-service/pull/5607#discussion_r3119473830 --- crates/handlers/src/compat/login.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 803a401f0..63ce04f14 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -634,6 +634,25 @@ async fn process_violations_for_compat_login( // Remove the sessions (only as much as necessary, `need_to_remove`) for compat_session in &lru_compat_sessions[0..need_to_remove] { + // Log what's happening so we have some explanation if someone asks + // + // FIXME: In the future, it would probably good to mark the reason + // down in the database for a better paper trail. + tracing::info!( + // So we can easily find logs for a given user + user_id = user.id.to_string(), + username = user.username, + // So we can easily look it up in the MAS database + compat_session_id = compat_session.id.to_string(), + // Make it easier to line up with what the user may be talking about + device_id = compat_session + .device + .as_ref() + .map(mas_data_model::Device::as_str), + "Automatically removing compat session for user (`hard_limit_eviction`)" + ); + + // Remove the session repo.compat_session() .finish(clock, compat_session.to_owned()) .await?; From 4073c41958918ba9fcef843d160fcb5d63765009 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Tue, 21 Apr 2026 16:07:01 -0500 Subject: [PATCH 09/31] Fix `havea` -> `have a` typo See https://github.com/element-hq/matrix-authentication-service/pull/5607#discussion_r3119473853 --- crates/handlers/src/compat/login.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 63ce04f14..474efc213 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -1928,7 +1928,7 @@ mod tests { .0 .device .clone() - .expect("Expected each login should havea a device") + .expect("Expected each login should have a device") .as_str() .to_owned() }) @@ -2059,7 +2059,7 @@ mod tests { .0 .device .clone() - .expect("Expected each login should havea a device") + .expect("Expected each login should have a device") .as_str() .to_owned() }) From 3d5c3b01a4a7612b36c71b40a4138ae522683c58 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Tue, 21 Apr 2026 17:35:23 -0500 Subject: [PATCH 10/31] Fix session replacement tests --- policies/compat_login/compat_login_test.rego | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/policies/compat_login/compat_login_test.rego b/policies/compat_login/compat_login_test.rego index 1b8049844..a8d553474 100644 --- a/policies/compat_login/compat_login_test.rego +++ b/policies/compat_login/compat_login_test.rego @@ -84,16 +84,18 @@ test_session_limiting_password if { with data.session_limit as null } +# If the session is replacing an existing session, no need to throw any violations about +# too many sessions test_no_session_limiting_upon_replacement if { - not compat_login.allow with input.user as user + compat_login.allow with input.user as user with input.session_counts as {"total": 65} with input.login as {"type": "m.login.password"} - with input.session_replaced as false + with input.session_replaced as true with data.session_limit as {"soft_limit": 32, "hard_limit": 64} - not compat_login.allow with input.user as user + compat_login.allow with input.user as user with input.session_counts as {"total": 65} with input.login as {"type": "m.login.sso"} - with input.session_replaced as false + with input.session_replaced as true with data.session_limit as {"soft_limit": 32, "hard_limit": 64} } From 1b96000cedc4a1cc6ccbdea8f94a40c763e1d2e8 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Tue, 21 Apr 2026 17:38:49 -0500 Subject: [PATCH 11/31] Use correct limit --- policies/compat_login/compat_login.rego | 2 +- policies/compat_login/compat_login_test.rego | 146 ++++++++++++++++--- 2 files changed, 123 insertions(+), 25 deletions(-) diff --git a/policies/compat_login/compat_login.rego b/policies/compat_login/compat_login.rego index dd505f8a5..5f1825268 100644 --- a/policies/compat_login/compat_login.rego +++ b/policies/compat_login/compat_login.rego @@ -30,7 +30,7 @@ violation contains { "msg": "user has too many active sessions (soft limit)", # `+ 1` because when you're at 2 sessions, and the limit is 2, you have to make room # for the new session - "need_to_remove": (input.session_counts.total - data.session_limit.hard_limit) + 1, + "need_to_remove": (input.session_counts.total - data.session_limit.soft_limit) + 1, } if { # Only apply if session limits are enabled in the config data.session_limit != null diff --git a/policies/compat_login/compat_login_test.rego b/policies/compat_login/compat_login_test.rego index a8d553474..09f18f30c 100644 --- a/policies/compat_login/compat_login_test.rego +++ b/policies/compat_login/compat_login_test.rego @@ -10,92 +10,190 @@ import rego.v1 user := {"username": "john"} +# Helper utility to extract the number of sessions that they `need_to_remove`, returns 0 +# if the `too-many-sessions` violation is not found +get_need_to_remove(violations) := need if { + some v in violations + v.code == "too-many-sessions" + need := v.need_to_remove +} else := 0 + # Tests session limiting when using (the interactive part of) `m.login.sso` -test_session_limiting_sso if { - compat_login.allow with input.user as user +# (interactive, therefore `soft_limit` applies) +# ========================================================================= +test_session_limiting_sso_under_limit if { + result := { + "allow": compat_login.allow, + "need_to_remove": get_need_to_remove(compat_login.violation), + } with input.user as user with input.session_counts as {"total": 1} with input.login as {"type": "m.login.sso"} with input.session_replaced as false with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + result.allow + result.need_to_remove == 0 +} - compat_login.allow with input.user as user +test_session_limiting_sso_barely_under_soft_limit if { + result := { + "allow": compat_login.allow, + "need_to_remove": get_need_to_remove(compat_login.violation), + } with input.user as user with input.session_counts as {"total": 31} with input.login as {"type": "m.login.sso"} with input.session_replaced as false with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + result.allow + result.need_to_remove == 0 +} - not compat_login.allow with input.user as user +test_session_limiting_sso_hit_soft_limit if { + result := { + "allow": compat_login.allow, + "need_to_remove": get_need_to_remove(compat_login.violation), + } with input.user as user with input.session_counts as {"total": 32} with input.login as {"type": "m.login.sso"} with input.session_replaced as false with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + not result.allow + result.need_to_remove == 1 +} - not compat_login.allow with input.user as user +test_session_limiting_sso_over_soft_limit if { + result := { + "allow": compat_login.allow, + "need_to_remove": get_need_to_remove(compat_login.violation), + } with input.user as user with input.session_counts as {"total": 42} with input.login as {"type": "m.login.sso"} with input.session_replaced as false with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + not result.allow + result.need_to_remove == 11 +} - not compat_login.allow with input.user as user +test_session_limiting_sso_over_hard_limit if { + result := { + "allow": compat_login.allow, + "need_to_remove": get_need_to_remove(compat_login.violation), + } with input.user as user with input.session_counts as {"total": 65} with input.login as {"type": "m.login.sso"} with input.session_replaced as false with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + not result.allow + # Only the soft-limit applies to the interactive `m.login.sso` login + result.need_to_remove == 34 +} - # No limit configured - compat_login.allow with input.user as user +test_session_limiting_sso_no_limit if { + result := { + "allow": compat_login.allow, + "need_to_remove": get_need_to_remove(compat_login.violation), + } with input.user as user with input.session_counts as {"total": 1} with input.login as {"type": "m.login.sso"} with input.session_replaced as false + # No limit configured with data.session_limit as null + result.allow + result.need_to_remove == 0 } -# Test session limiting when using `m.login.password` -test_session_limiting_password if { - compat_login.allow with input.user as user +# Test session limiting when using `m.login.password` (not interactive, therefore +# `hard_limit` applies) +# ========================================================================= +test_session_limiting_password_under_limit if { + result := { + "allow": compat_login.allow, + "need_to_remove": get_need_to_remove(compat_login.violation), + } with input.user as user with input.session_counts as {"total": 1} with input.login as {"type": "m.login.password"} with input.session_replaced as false with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + result.allow + result.need_to_remove == 0 +} - compat_login.allow with input.user as user +test_session_limiting_password_under_hard_limit if { + result := { + "allow": compat_login.allow, + "need_to_remove": get_need_to_remove(compat_login.violation), + } with input.user as user with input.session_counts as {"total": 63} with input.login as {"type": "m.login.password"} with input.session_replaced as false with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + result.allow + result.need_to_remove == 0 +} - not compat_login.allow with input.user as user +test_session_limiting_password_hit_hard_limit if { + result := { + "allow": compat_login.allow, + "need_to_remove": get_need_to_remove(compat_login.violation), + } with input.user as user with input.session_counts as {"total": 64} with input.login as {"type": "m.login.password"} with input.session_replaced as false with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + not result.allow + result.need_to_remove == 1 +} - not compat_login.allow with input.user as user +test_session_limiting_password_over_hard_limit if { + result := { + "allow": compat_login.allow, + "need_to_remove": get_need_to_remove(compat_login.violation), + } with input.user as user with input.session_counts as {"total": 65} with input.login as {"type": "m.login.password"} with input.session_replaced as false with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + not result.allow + result.need_to_remove == 2 +} - # No limit configured - compat_login.allow with input.user as user +test_session_limiting_password_no_limit if { + result := { + "allow": compat_login.allow, + "need_to_remove": get_need_to_remove(compat_login.violation), + } with input.user as user with input.session_counts as {"total": 1} with input.login as {"type": "m.login.password"} with input.session_replaced as false + # No limit configured with data.session_limit as null + result.allow + result.need_to_remove == 0 } # If the session is replacing an existing session, no need to throw any violations about # too many sessions -test_no_session_limiting_upon_replacement if { - compat_login.allow with input.user as user - with input.session_counts as {"total": 65} - with input.login as {"type": "m.login.password"} - with input.session_replaced as true - with data.session_limit as {"soft_limit": 32, "hard_limit": 64} - - compat_login.allow with input.user as user +test_no_session_limiting_sso_upon_replacement if { + result := { + "allow": compat_login.allow, + "need_to_remove": get_need_to_remove(compat_login.violation), + } with input.user as user with input.session_counts as {"total": 65} with input.login as {"type": "m.login.sso"} with input.session_replaced as true with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + result.allow + result.need_to_remove == 0 +} + +test_no_session_limiting_password_upon_replacement if { + result := { + "allow": compat_login.allow, + "need_to_remove": get_need_to_remove(compat_login.violation), + } with input.user as user + with input.session_counts as {"total": 65} + with input.login as {"type": "m.login.password"} + with input.session_replaced as true + with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + result.allow + result.need_to_remove == 0 } From abe4c3519432f2f2afeb22b92505bbe6717f8342 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Tue, 21 Apr 2026 17:38:57 -0500 Subject: [PATCH 12/31] Add tests for `need_to_remove` See https://github.com/element-hq/matrix-authentication-service/pull/5607#discussion_r3119473825 --- policies/authorization_grant/authorization_grant.rego | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/policies/authorization_grant/authorization_grant.rego b/policies/authorization_grant/authorization_grant.rego index eca6ccbcf..34334d36b 100644 --- a/policies/authorization_grant/authorization_grant.rego +++ b/policies/authorization_grant/authorization_grant.rego @@ -159,7 +159,7 @@ violation contains { "msg": "user has too many active sessions", # `+ 1` because when you're at 2 sessions, and the limit is 2, you have to make room # for the new session - "need_to_remove": (input.session_counts.total - data.session_limit.hard_limit) + 1, + "need_to_remove": (input.session_counts.total - data.session_limit.soft_limit) + 1, } if { # Only apply if session limits are enabled in the config data.session_limit != null From f30bf47e8208cae593db21ef8563bd01938e454c Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Tue, 21 Apr 2026 17:47:01 -0500 Subject: [PATCH 13/31] Add `need_to_remove` policy tests for authorization grant --- .../authorization_grant_test.rego | 79 +++++++++++++++---- policies/compat_login/compat_login_test.rego | 2 +- 2 files changed, 64 insertions(+), 17 deletions(-) diff --git a/policies/authorization_grant/authorization_grant_test.rego b/policies/authorization_grant/authorization_grant_test.rego index e2ca74086..45b01c676 100644 --- a/policies/authorization_grant/authorization_grant_test.rego +++ b/policies/authorization_grant/authorization_grant_test.rego @@ -223,34 +223,81 @@ test_mas_scopes if { with input.scope as "urn:mas:admin" } -test_session_limiting if { - authorization_grant.allow with input.user as user +# Helper utility to extract the number of sessions that they `need_to_remove`, returns 0 +# if the `too-many-sessions` violation is not found +get_need_to_remove(violations) := need if { + some v in violations + v.code == "too-many-sessions" + need := v.need_to_remove +} else := 0 + +# Tests session limiting when using OAuth 2.0 authorization grants +# (interactive, therefore `soft_limit` applies) +# ========================================================================= +test_session_limiting_under_limit if { + result := { + "allow": authorization_grant.allow, + "need_to_remove": get_need_to_remove(authorization_grant.violation), + } with input.user as user with input.session_counts as {"total": 1} with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + result.allow + result.need_to_remove == 0 +} - authorization_grant.allow with input.user as user +test_session_limiting_under_soft_limit if { + result := { + "allow": authorization_grant.allow, + "need_to_remove": get_need_to_remove(authorization_grant.violation), + } with input.user as user with input.session_counts as {"total": 31} with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + result.allow + result.need_to_remove == 0 +} - not authorization_grant.allow with input.user as user +test_session_limiting_hit_soft_limit if { + result := { + "allow": authorization_grant.allow, + "need_to_remove": get_need_to_remove(authorization_grant.violation), + } with input.user as user with input.session_counts as {"total": 32} with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + not result.allow + result.need_to_remove == 1 +} - not authorization_grant.allow with input.user as user +test_session_limiting_over_soft_limit if { + result := { + "allow": authorization_grant.allow, + "need_to_remove": get_need_to_remove(authorization_grant.violation), + } with input.user as user with input.session_counts as {"total": 42} with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + not result.allow + result.need_to_remove == 11 +} - not authorization_grant.allow with input.user as user +test_session_limiting_over_soft_limit if { + result := { + "allow": authorization_grant.allow, + "need_to_remove": get_need_to_remove(authorization_grant.violation), + } with input.user as user with input.session_counts as {"total": 65} with data.session_limit as {"soft_limit": 32, "hard_limit": 64} - - # No limit configured - authorization_grant.allow with input.user as user - with input.session_counts as {"total": 1} - with data.session_limit as null - - # Client credentials grant - authorization_grant.allow with input.user as user - with input.session_counts as null - with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + not result.allow + # Only the `soft_limit` applies to the interactive login + result.need_to_remove == 34 +} + +test_session_limiting_no_limit if { + result := { + "allow": authorization_grant.allow, + "need_to_remove": get_need_to_remove(authorization_grant.violation), + } with input.user as user + with input.session_counts as {"total": 1} + # No limit configured + with data.session_limit as null + result.allow + result.need_to_remove == 0 } diff --git a/policies/compat_login/compat_login_test.rego b/policies/compat_login/compat_login_test.rego index 09f18f30c..2aee0eaa0 100644 --- a/policies/compat_login/compat_login_test.rego +++ b/policies/compat_login/compat_login_test.rego @@ -83,7 +83,7 @@ test_session_limiting_sso_over_hard_limit if { with input.session_replaced as false with data.session_limit as {"soft_limit": 32, "hard_limit": 64} not result.allow - # Only the soft-limit applies to the interactive `m.login.sso` login + # Only the `soft_limit` applies to the interactive `m.login.sso` login result.need_to_remove == 34 } From 6b59e355834bead9beb3636043ef71b06ba7b73a Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Tue, 21 Apr 2026 18:09:19 -0500 Subject: [PATCH 14/31] Automatic formatting/linting and more `at least` typos --- crates/config/src/sections/experimental.rs | 6 +++--- crates/handlers/src/compat/login.rs | 4 ++-- docs/config.schema.json | 4 ++-- policies/authorization_grant/authorization_grant_test.rego | 3 ++- policies/compat_login/compat_login_test.rego | 5 +++-- 5 files changed, 12 insertions(+), 10 deletions(-) diff --git a/crates/config/src/sections/experimental.rs b/crates/config/src/sections/experimental.rs index d9326be63..a0882c1cb 100644 --- a/crates/config/src/sections/experimental.rs +++ b/crates/config/src/sections/experimental.rs @@ -173,9 +173,9 @@ pub struct SessionLimitConfig { /// recovery key setup. /// /// When using [`hard_limit_eviction`], the [`hard_limit`] must be - /// at-least 2 to avoid catastrophically losing encrypted history and digital - /// identity in pathological cases. Keep in mind this is a bare minimum - /// restriction and you can still run into trouble. + /// at least 2 to avoid catastrophically losing encrypted history and + /// digital identity in pathological cases. Keep in mind this is a bare + /// minimum restriction and you can still run into trouble. /// /// This is most applicable in scenarios where your homeserver has many /// legacy bots/scripts that login over and over (which ideally should diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 474efc213..a59997d91 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -1810,7 +1810,7 @@ mod tests { session_limit: Some(SessionLimitConfig { // (doesn't matter) soft_limit: NonZeroU64::new(1).unwrap(), - // Must be at-least 2 when `hard_limit_eviction` + // Must be at least 2 when `hard_limit_eviction` hard_limit: NonZeroU64::new(2).unwrap(), // Option under test hard_limit_eviction: true, @@ -1968,7 +1968,7 @@ mod tests { session_limit: Some(SessionLimitConfig { // (doesn't matter) soft_limit: NonZeroU64::new(1).unwrap(), - // Must be at-least 2 when `hard_limit_eviction` + // Must be at least 2 when `hard_limit_eviction` hard_limit: NonZeroU64::new(2).unwrap(), // Option under test hard_limit_eviction: true, diff --git a/docs/config.schema.json b/docs/config.schema.json index 369be92ba..ab7692ead 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -2910,7 +2910,7 @@ "minimum": 1 }, "hard_limit_eviction": { - "description": "Whether we should automatically choose the least recently used devices\n to remove when the [`Self::hard_limit`] is reached; in order to\n allow the new login to continue.\n\n Disabled by default\n\n WARNING: Removing sessions is a potentially damaging operation. Any\n end-to-end encrypted history on the device will be lost and can only\n be recovered if you have another verified active device or have a\n recovery key setup.\n\n When using [`hard_limit_eviction`], the [`hard_limit`] must be\n at-least 2 to avoid catastrophically losing encrypted history and digital\n identity in pathological cases. Keep in mind this is a bare minimum\n restriction and you can still run into trouble.\n\n This is most applicable in scenarios where your homeserver has many\n legacy bots/scripts that login over and over (which ideally should\n be using [personal access\n tokens](https://github.com/element-hq/matrix-authentication-service/issues/4492))\n and you want to avoid breaking their operation while maintaining some\n level of sanity with the number of devices that people can have.\n\n [`hard_limit`]: Self::hard_limit\n [`hard_limit_eviction`]: Self::hard_limit_eviction", + "description": "Whether we should automatically choose the least recently used devices\n to remove when the [`Self::hard_limit`] is reached; in order to\n allow the new login to continue.\n\n Disabled by default\n\n WARNING: Removing sessions is a potentially damaging operation. Any\n end-to-end encrypted history on the device will be lost and can only\n be recovered if you have another verified active device or have a\n recovery key setup.\n\n When using [`hard_limit_eviction`], the [`hard_limit`] must be\n at least 2 to avoid catastrophically losing encrypted history and\n digital identity in pathological cases. Keep in mind this is a bare\n minimum restriction and you can still run into trouble.\n\n This is most applicable in scenarios where your homeserver has many\n legacy bots/scripts that login over and over (which ideally should\n be using [personal access\n tokens](https://github.com/element-hq/matrix-authentication-service/issues/4492))\n and you want to avoid breaking their operation while maintaining some\n level of sanity with the number of devices that people can have.\n\n [`hard_limit`]: Self::hard_limit\n [`hard_limit_eviction`]: Self::hard_limit_eviction", "type": "boolean", "default": false } @@ -2921,4 +2921,4 @@ ] } } -} +} \ No newline at end of file diff --git a/policies/authorization_grant/authorization_grant_test.rego b/policies/authorization_grant/authorization_grant_test.rego index 45b01c676..8a20aedb0 100644 --- a/policies/authorization_grant/authorization_grant_test.rego +++ b/policies/authorization_grant/authorization_grant_test.rego @@ -286,17 +286,18 @@ test_session_limiting_over_soft_limit if { with input.session_counts as {"total": 65} with data.session_limit as {"soft_limit": 32, "hard_limit": 64} not result.allow + # Only the `soft_limit` applies to the interactive login result.need_to_remove == 34 } test_session_limiting_no_limit if { + # No limit configured result := { "allow": authorization_grant.allow, "need_to_remove": get_need_to_remove(authorization_grant.violation), } with input.user as user with input.session_counts as {"total": 1} - # No limit configured with data.session_limit as null result.allow result.need_to_remove == 0 diff --git a/policies/compat_login/compat_login_test.rego b/policies/compat_login/compat_login_test.rego index 2aee0eaa0..dfb9c0328 100644 --- a/policies/compat_login/compat_login_test.rego +++ b/policies/compat_login/compat_login_test.rego @@ -83,11 +83,13 @@ test_session_limiting_sso_over_hard_limit if { with input.session_replaced as false with data.session_limit as {"soft_limit": 32, "hard_limit": 64} not result.allow + # Only the `soft_limit` applies to the interactive `m.login.sso` login result.need_to_remove == 34 } test_session_limiting_sso_no_limit if { + # No limit configured result := { "allow": compat_login.allow, "need_to_remove": get_need_to_remove(compat_login.violation), @@ -95,7 +97,6 @@ test_session_limiting_sso_no_limit if { with input.session_counts as {"total": 1} with input.login as {"type": "m.login.sso"} with input.session_replaced as false - # No limit configured with data.session_limit as null result.allow result.need_to_remove == 0 @@ -157,6 +158,7 @@ test_session_limiting_password_over_hard_limit if { } test_session_limiting_password_no_limit if { + # No limit configured result := { "allow": compat_login.allow, "need_to_remove": get_need_to_remove(compat_login.violation), @@ -164,7 +166,6 @@ test_session_limiting_password_no_limit if { with input.session_counts as {"total": 1} with input.login as {"type": "m.login.password"} with input.session_replaced as false - # No limit configured with data.session_limit as null result.allow result.need_to_remove == 0 From dcf42a842fd6446db53671136f28be4476f78814 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Tue, 21 Apr 2026 18:17:26 -0500 Subject: [PATCH 15/31] Fix policy lints --- .../authorization_grant_test.rego | 28 +++++----- policies/compat_login/compat_login_test.rego | 54 +++++++++---------- 2 files changed, 41 insertions(+), 41 deletions(-) diff --git a/policies/authorization_grant/authorization_grant_test.rego b/policies/authorization_grant/authorization_grant_test.rego index 8a20aedb0..4bf6442f5 100644 --- a/policies/authorization_grant/authorization_grant_test.rego +++ b/policies/authorization_grant/authorization_grant_test.rego @@ -225,7 +225,7 @@ test_mas_scopes if { # Helper utility to extract the number of sessions that they `need_to_remove`, returns 0 # if the `too-many-sessions` violation is not found -get_need_to_remove(violations) := need if { +need_to_remove_sessions(violations) := need if { some v in violations v.code == "too-many-sessions" need := v.need_to_remove @@ -237,68 +237,68 @@ get_need_to_remove(violations) := need if { test_session_limiting_under_limit if { result := { "allow": authorization_grant.allow, - "need_to_remove": get_need_to_remove(authorization_grant.violation), + "need_to_remove_sessions": need_to_remove_sessions(authorization_grant.violation), } with input.user as user with input.session_counts as {"total": 1} with data.session_limit as {"soft_limit": 32, "hard_limit": 64} result.allow - result.need_to_remove == 0 + result.need_to_remove_sessions == 0 } test_session_limiting_under_soft_limit if { result := { "allow": authorization_grant.allow, - "need_to_remove": get_need_to_remove(authorization_grant.violation), + "need_to_remove_sessions": need_to_remove_sessions(authorization_grant.violation), } with input.user as user with input.session_counts as {"total": 31} with data.session_limit as {"soft_limit": 32, "hard_limit": 64} result.allow - result.need_to_remove == 0 + result.need_to_remove_sessions == 0 } test_session_limiting_hit_soft_limit if { result := { "allow": authorization_grant.allow, - "need_to_remove": get_need_to_remove(authorization_grant.violation), + "need_to_remove_sessions": need_to_remove_sessions(authorization_grant.violation), } with input.user as user with input.session_counts as {"total": 32} with data.session_limit as {"soft_limit": 32, "hard_limit": 64} not result.allow - result.need_to_remove == 1 + result.need_to_remove_sessions == 1 } test_session_limiting_over_soft_limit if { result := { "allow": authorization_grant.allow, - "need_to_remove": get_need_to_remove(authorization_grant.violation), + "need_to_remove_sessions": need_to_remove_sessions(authorization_grant.violation), } with input.user as user with input.session_counts as {"total": 42} with data.session_limit as {"soft_limit": 32, "hard_limit": 64} not result.allow - result.need_to_remove == 11 + result.need_to_remove_sessions == 11 } -test_session_limiting_over_soft_limit if { +test_session_limiting_over_hard_limit if { result := { "allow": authorization_grant.allow, - "need_to_remove": get_need_to_remove(authorization_grant.violation), + "need_to_remove_sessions": need_to_remove_sessions(authorization_grant.violation), } with input.user as user with input.session_counts as {"total": 65} with data.session_limit as {"soft_limit": 32, "hard_limit": 64} not result.allow # Only the `soft_limit` applies to the interactive login - result.need_to_remove == 34 + result.need_to_remove_sessions == 34 } test_session_limiting_no_limit if { # No limit configured result := { "allow": authorization_grant.allow, - "need_to_remove": get_need_to_remove(authorization_grant.violation), + "need_to_remove_sessions": need_to_remove_sessions(authorization_grant.violation), } with input.user as user with input.session_counts as {"total": 1} with data.session_limit as null result.allow - result.need_to_remove == 0 + result.need_to_remove_sessions == 0 } diff --git a/policies/compat_login/compat_login_test.rego b/policies/compat_login/compat_login_test.rego index dfb9c0328..339b1b684 100644 --- a/policies/compat_login/compat_login_test.rego +++ b/policies/compat_login/compat_login_test.rego @@ -12,7 +12,7 @@ user := {"username": "john"} # Helper utility to extract the number of sessions that they `need_to_remove`, returns 0 # if the `too-many-sessions` violation is not found -get_need_to_remove(violations) := need if { +need_to_remove_sessions(violations) := need if { some v in violations v.code == "too-many-sessions" need := v.need_to_remove @@ -24,59 +24,59 @@ get_need_to_remove(violations) := need if { test_session_limiting_sso_under_limit if { result := { "allow": compat_login.allow, - "need_to_remove": get_need_to_remove(compat_login.violation), + "need_to_remove_sessions": need_to_remove_sessions(compat_login.violation), } with input.user as user with input.session_counts as {"total": 1} with input.login as {"type": "m.login.sso"} with input.session_replaced as false with data.session_limit as {"soft_limit": 32, "hard_limit": 64} result.allow - result.need_to_remove == 0 + result.need_to_remove_sessions == 0 } test_session_limiting_sso_barely_under_soft_limit if { result := { "allow": compat_login.allow, - "need_to_remove": get_need_to_remove(compat_login.violation), + "need_to_remove_sessions": need_to_remove_sessions(compat_login.violation), } with input.user as user with input.session_counts as {"total": 31} with input.login as {"type": "m.login.sso"} with input.session_replaced as false with data.session_limit as {"soft_limit": 32, "hard_limit": 64} result.allow - result.need_to_remove == 0 + result.need_to_remove_sessions == 0 } test_session_limiting_sso_hit_soft_limit if { result := { "allow": compat_login.allow, - "need_to_remove": get_need_to_remove(compat_login.violation), + "need_to_remove_sessions": need_to_remove_sessions(compat_login.violation), } with input.user as user with input.session_counts as {"total": 32} with input.login as {"type": "m.login.sso"} with input.session_replaced as false with data.session_limit as {"soft_limit": 32, "hard_limit": 64} not result.allow - result.need_to_remove == 1 + result.need_to_remove_sessions == 1 } test_session_limiting_sso_over_soft_limit if { result := { "allow": compat_login.allow, - "need_to_remove": get_need_to_remove(compat_login.violation), + "need_to_remove_sessions": need_to_remove_sessions(compat_login.violation), } with input.user as user with input.session_counts as {"total": 42} with input.login as {"type": "m.login.sso"} with input.session_replaced as false with data.session_limit as {"soft_limit": 32, "hard_limit": 64} not result.allow - result.need_to_remove == 11 + result.need_to_remove_sessions == 11 } test_session_limiting_sso_over_hard_limit if { result := { "allow": compat_login.allow, - "need_to_remove": get_need_to_remove(compat_login.violation), + "need_to_remove_sessions": need_to_remove_sessions(compat_login.violation), } with input.user as user with input.session_counts as {"total": 65} with input.login as {"type": "m.login.sso"} @@ -85,21 +85,21 @@ test_session_limiting_sso_over_hard_limit if { not result.allow # Only the `soft_limit` applies to the interactive `m.login.sso` login - result.need_to_remove == 34 + result.need_to_remove_sessions == 34 } test_session_limiting_sso_no_limit if { # No limit configured result := { "allow": compat_login.allow, - "need_to_remove": get_need_to_remove(compat_login.violation), + "need_to_remove_sessions": need_to_remove_sessions(compat_login.violation), } with input.user as user with input.session_counts as {"total": 1} with input.login as {"type": "m.login.sso"} with input.session_replaced as false with data.session_limit as null result.allow - result.need_to_remove == 0 + result.need_to_remove_sessions == 0 } # Test session limiting when using `m.login.password` (not interactive, therefore @@ -108,67 +108,67 @@ test_session_limiting_sso_no_limit if { test_session_limiting_password_under_limit if { result := { "allow": compat_login.allow, - "need_to_remove": get_need_to_remove(compat_login.violation), + "need_to_remove_sessions": need_to_remove_sessions(compat_login.violation), } with input.user as user with input.session_counts as {"total": 1} with input.login as {"type": "m.login.password"} with input.session_replaced as false with data.session_limit as {"soft_limit": 32, "hard_limit": 64} result.allow - result.need_to_remove == 0 + result.need_to_remove_sessions == 0 } test_session_limiting_password_under_hard_limit if { result := { "allow": compat_login.allow, - "need_to_remove": get_need_to_remove(compat_login.violation), + "need_to_remove_sessions": need_to_remove_sessions(compat_login.violation), } with input.user as user with input.session_counts as {"total": 63} with input.login as {"type": "m.login.password"} with input.session_replaced as false with data.session_limit as {"soft_limit": 32, "hard_limit": 64} result.allow - result.need_to_remove == 0 + result.need_to_remove_sessions == 0 } test_session_limiting_password_hit_hard_limit if { result := { "allow": compat_login.allow, - "need_to_remove": get_need_to_remove(compat_login.violation), + "need_to_remove_sessions": need_to_remove_sessions(compat_login.violation), } with input.user as user with input.session_counts as {"total": 64} with input.login as {"type": "m.login.password"} with input.session_replaced as false with data.session_limit as {"soft_limit": 32, "hard_limit": 64} not result.allow - result.need_to_remove == 1 + result.need_to_remove_sessions == 1 } test_session_limiting_password_over_hard_limit if { result := { "allow": compat_login.allow, - "need_to_remove": get_need_to_remove(compat_login.violation), + "need_to_remove_sessions": need_to_remove_sessions(compat_login.violation), } with input.user as user with input.session_counts as {"total": 65} with input.login as {"type": "m.login.password"} with input.session_replaced as false with data.session_limit as {"soft_limit": 32, "hard_limit": 64} not result.allow - result.need_to_remove == 2 + result.need_to_remove_sessions == 2 } test_session_limiting_password_no_limit if { # No limit configured result := { "allow": compat_login.allow, - "need_to_remove": get_need_to_remove(compat_login.violation), + "need_to_remove_sessions": need_to_remove_sessions(compat_login.violation), } with input.user as user with input.session_counts as {"total": 1} with input.login as {"type": "m.login.password"} with input.session_replaced as false with data.session_limit as null result.allow - result.need_to_remove == 0 + result.need_to_remove_sessions == 0 } # If the session is replacing an existing session, no need to throw any violations about @@ -176,25 +176,25 @@ test_session_limiting_password_no_limit if { test_no_session_limiting_sso_upon_replacement if { result := { "allow": compat_login.allow, - "need_to_remove": get_need_to_remove(compat_login.violation), + "need_to_remove_sessions": need_to_remove_sessions(compat_login.violation), } with input.user as user with input.session_counts as {"total": 65} with input.login as {"type": "m.login.sso"} with input.session_replaced as true with data.session_limit as {"soft_limit": 32, "hard_limit": 64} result.allow - result.need_to_remove == 0 + result.need_to_remove_sessions == 0 } test_no_session_limiting_password_upon_replacement if { result := { "allow": compat_login.allow, - "need_to_remove": get_need_to_remove(compat_login.violation), + "need_to_remove_sessions": need_to_remove_sessions(compat_login.violation), } with input.user as user with input.session_counts as {"total": 65} with input.login as {"type": "m.login.password"} with input.session_replaced as true with data.session_limit as {"soft_limit": 32, "hard_limit": 64} result.allow - result.need_to_remove == 0 + result.need_to_remove_sessions == 0 } From 8787fbb4596bab3602050daf9a8b5837f567b339 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 01:20:28 +0000 Subject: [PATCH 16/31] build(deps): bump rustls-webpki from 0.103.10 to 0.103.13 Bumps [rustls-webpki](https://github.com/rustls/webpki) from 0.103.10 to 0.103.13. - [Release notes](https://github.com/rustls/webpki/releases) - [Commits](https://github.com/rustls/webpki/compare/v/0.103.10...v/0.103.13) --- updated-dependencies: - dependency-name: rustls-webpki dependency-version: 0.103.13 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 39a054965..7af27529d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5198,9 +5198,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.10" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "aws-lc-rs", "ring", From 29f7d986eeb8eddefeb8bd758cc2b265ed0aa85e Mon Sep 17 00:00:00 2001 From: Olivier 'reivilibre Date: Thu, 23 Apr 2026 14:16:01 +0100 Subject: [PATCH 17/31] Add characterisation test --- .../src/oauth2/authorization/callback.rs | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/crates/handlers/src/oauth2/authorization/callback.rs b/crates/handlers/src/oauth2/authorization/callback.rs index 01f59d602..33311fe39 100644 --- a/crates/handlers/src/oauth2/authorization/callback.rs +++ b/crates/handlers/src/oauth2/authorization/callback.rs @@ -164,3 +164,79 @@ impl CallbackDestination { } } } + +#[cfg(test)] +mod tests { + use hyper::{Request, StatusCode}; + use mas_router::SimpleRoute; + use oauth2_types::registration::ClientRegistrationResponse; + use sqlx::PgPool; + use url::Url; + + use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; + + /// Helper to build a GET request to the authorization endpoint with query + /// parameters. + fn authorize_get_request( + client_id: &str, + redirect_uri: &str, + state: &str, + ) -> hyper::Request { + let mut url = Url::parse("https://example.com/authorize").unwrap(); + url.query_pairs_mut() + .append_pair("response_type", "code") + .append_pair("client_id", client_id) + .append_pair("redirect_uri", redirect_uri) + .append_pair("scope", "openid") + .append_pair("state", state) + .append_pair("response_mode", "query") + .append_pair("prompt", "none"); + + let path = url.path(); + let query = url.query().unwrap_or(""); + let uri = format!("{path}?{query}"); + Request::get(uri).empty() + } + + /// Test that checks the content of the `Location` header + /// in response to an authorization request. + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_query_mode_location_header(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + + // Register an OAuth2 client + let request = + Request::post(mas_router::OAuth2RegistrationEndpoint::PATH).json(serde_json::json!({ + "client_uri": "https://example.com/", + "redirect_uris": ["https://example.com/callback"], + "token_endpoint_auth_method": "none", + "response_types": ["code"], + "grant_types": ["authorization_code"], + })); + + let response = state.request(request).await; + response.assert_status(StatusCode::CREATED); + + let registration: ClientRegistrationResponse = response.json(); + let client_id = registration.client_id; + + // Send an authorization request with response_mode=query and prompt=none. + // prompt=none always fails with login_required since there is no session, + // which exercises the CallbackDestinationMode::Query path. + let request = authorize_get_request( + &client_id, + "https://example.com/callback", + "test-state-value", + ); + let response = state.request(request).await; + + response.assert_status(StatusCode::SEE_OTHER); + + // Check the form of the Location redirect + response.assert_header_value( + hyper::header::LOCATION, + "https://example.com/callback?state=test-state-value&error=login_required&error_description=The+Authorization+Server+requires+End-User+authentication.", + ); + } +} From f50d2e53cd7cc43e7f767bc1d25d60e96ad0f1f7 Mon Sep 17 00:00:00 2001 From: Olivier 'reivilibre Date: Thu, 23 Apr 2026 14:16:57 +0100 Subject: [PATCH 18/31] Overwrite the fragment with `#` on query callback mode --- .../src/oauth2/authorization/callback.rs | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/crates/handlers/src/oauth2/authorization/callback.rs b/crates/handlers/src/oauth2/authorization/callback.rs index 33311fe39..2b08b3f78 100644 --- a/crates/handlers/src/oauth2/authorization/callback.rs +++ b/crates/handlers/src/oauth2/authorization/callback.rs @@ -133,6 +133,20 @@ impl CallbackDestination { let new_qs = serde_urlencoded::to_string(merged)?; redirect_uri.set_query(Some(&new_qs)); + if redirect_uri.fragment().is_none() { + // To avoid the browser carrying over any potential URL fragment, which may + // include sensitive data from an upstream identity provider, across the + // redirect, overwrite the fragment part by setting the + // fragment to `#`. + // + // The browser behaviour is documented as part of + // the 'location URL' algorithm at + // https://fetch.spec.whatwg.org/commit-snapshots/809904366f33a673a9489b81155ee9e3edd29c12/#concept-response-location-url + // + // We don't need to do this if the redirect URI already includes + // a fragment (as that will also overwrite the client's current value). + redirect_uri.set_fragment(Some("")); + } Ok(Redirect::to(redirect_uri.as_str()).into_response()) } @@ -200,6 +214,10 @@ mod tests { /// Test that checks the content of the `Location` header /// in response to an authorization request. + /// + /// Specifically, we expect to see an empty fragment (`#`) + /// at the end of the URL in order to overwrite any fragment + /// that the browser might otherwise preserve across the redirect. #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] async fn test_query_mode_location_header(pool: PgPool) { setup(); @@ -236,7 +254,7 @@ mod tests { // Check the form of the Location redirect response.assert_header_value( hyper::header::LOCATION, - "https://example.com/callback?state=test-state-value&error=login_required&error_description=The+Authorization+Server+requires+End-User+authentication.", + "https://example.com/callback?state=test-state-value&error=login_required&error_description=The+Authorization+Server+requires+End-User+authentication.#", ); } } From 76865b239afa9f3cc17328822c9ff823f36c05fb Mon Sep 17 00:00:00 2001 From: Olivier 'reivilibre Date: Fri, 24 Apr 2026 13:32:38 +0100 Subject: [PATCH 19/31] Simplify comment and test --- .../src/oauth2/authorization/callback.rs | 69 ++++++++----------- 1 file changed, 30 insertions(+), 39 deletions(-) diff --git a/crates/handlers/src/oauth2/authorization/callback.rs b/crates/handlers/src/oauth2/authorization/callback.rs index 2b08b3f78..a7d7ca4a3 100644 --- a/crates/handlers/src/oauth2/authorization/callback.rs +++ b/crates/handlers/src/oauth2/authorization/callback.rs @@ -134,17 +134,23 @@ impl CallbackDestination { redirect_uri.set_query(Some(&new_qs)); if redirect_uri.fragment().is_none() { - // To avoid the browser carrying over any potential URL fragment, which may - // include sensitive data from an upstream identity provider, across the - // redirect, overwrite the fragment part by setting the - // fragment to `#`. + // Ensure that the Location header (redirect target) + // includes a URL fragment (#) of some sort. // - // The browser behaviour is documented as part of - // the 'location URL' algorithm at - // https://fetch.spec.whatwg.org/commit-snapshots/809904366f33a673a9489b81155ee9e3edd29c12/#concept-response-location-url + // Any fragment present in the Location header URL that the server redirects to + // (e.g., via a 303 response) will overwrite the client’s existing fragment, + // otherwise the fragment will be preserved across the + // redirect (and may contain sensitive information, + // or confuse the downstream client). // - // We don't need to do this if the redirect URI already includes - // a fragment (as that will also overwrite the client's current value). + // If the redirect_uri already contains a fragment, that fragment will do the + // same job, so we leave it alone — we don't want to mangle the client's + // configured redirect URL by replacing it with a blank fragment. + // Otherwise, set a fragment of empty string (effectively appending `#` to the + // URL). + // + // Browser behaviour is documented as part of the 'location URL' algorithm at + // https://fetch.spec.whatwg.org/commit-snapshots/809904366f33a673a9489b81155ee9e3edd29c12#concept-response-location-url redirect_uri.set_fragment(Some("")); } @@ -185,33 +191,9 @@ mod tests { use mas_router::SimpleRoute; use oauth2_types::registration::ClientRegistrationResponse; use sqlx::PgPool; - use url::Url; use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; - /// Helper to build a GET request to the authorization endpoint with query - /// parameters. - fn authorize_get_request( - client_id: &str, - redirect_uri: &str, - state: &str, - ) -> hyper::Request { - let mut url = Url::parse("https://example.com/authorize").unwrap(); - url.query_pairs_mut() - .append_pair("response_type", "code") - .append_pair("client_id", client_id) - .append_pair("redirect_uri", redirect_uri) - .append_pair("scope", "openid") - .append_pair("state", state) - .append_pair("response_mode", "query") - .append_pair("prompt", "none"); - - let path = url.path(); - let query = url.query().unwrap_or(""); - let uri = format!("{path}?{query}"); - Request::get(uri).empty() - } - /// Test that checks the content of the `Location` header /// in response to an authorization request. /// @@ -242,12 +224,21 @@ mod tests { // Send an authorization request with response_mode=query and prompt=none. // prompt=none always fails with login_required since there is no session, // which exercises the CallbackDestinationMode::Query path. - let request = authorize_get_request( - &client_id, - "https://example.com/callback", - "test-state-value", - ); - let response = state.request(request).await; + + // Build /authorize query parameters + let query = url::form_urlencoded::Serializer::new(String::new()) + .append_pair("response_type", "code") + .append_pair("client_id", &client_id) + .append_pair("redirect_uri", "https://example.com/callback") + .append_pair("scope", "openid") + .append_pair("state", "test-state-value") + .append_pair("response_mode", "query") + .append_pair("prompt", "none") + .finish(); + + let response = state + .request(Request::get(format!("https://example.com/authorize?{query}")).empty()) + .await; response.assert_status(StatusCode::SEE_OTHER); From 3e871eb2840021abbd823216750e4c06709892cd Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Fri, 24 Apr 2026 19:03:56 -0500 Subject: [PATCH 20/31] Time always goes forward See https://github.com/element-hq/matrix-authentication-service/pull/5607#discussion_r3119473850 --- crates/handlers/src/compat/login.rs | 68 ++++++++++++++++++----------- 1 file changed, 42 insertions(+), 26 deletions(-) diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index a59997d91..d29506372 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -54,6 +54,9 @@ static LOGIN_COUNTER: LazyLock> = LazyLock::new(|| { const TYPE: Key = Key::from_static_str("type"); const RESULT: Key = Key::from_static_str("result"); +/// This matches the `getNinetyDaysAgo()` used in the web UI for "inactive" sessions. +const INACTIVE_SESSION_THRESHOLD: chrono::TimeDelta = Duration::days(90); + #[derive(Debug, Serialize)] #[serde(tag = "type")] enum LoginType { @@ -557,7 +560,7 @@ async fn process_violations_for_compat_login( // `Ulid` (the query is ordered by `compat_session_id`), the // first bytes are a timestamp so we'll be getting the 'oldest // created' sessions which is another good proxy. - let inactive_threshold_date = clock.now() - Duration::days(90); + let inactive_threshold_date = clock.now() - INACTIVE_SESSION_THRESHOLD; let inactive_compat_session_page = repo .compat_session() .list( @@ -930,7 +933,11 @@ async fn user_password_login( #[cfg(test)] mod tests { - use std::{collections::HashSet, num::NonZeroU64, ops::Sub}; + use std::{ + collections::HashSet, + num::NonZeroU64, + ops::{Mul, Sub}, + }; use hyper::Request; use mas_matrix::{HomeserverConnection, ProvisionRequest}; @@ -1831,23 +1838,7 @@ mod tests { let mut login_device_ids: Vec = Vec::new(); - // Keep logging in to add more sessions, up to the `hard_limit`. Then `+ 1` for - // one more login will drop one of our old sessions to make room for the new - // login - #[allow(clippy::range_plus_one)] - for login_index in 0..(session_limit_config.hard_limit.get() + 1) { - let original_time = state.clock.now(); - // All of the logins except the last one should be in the past - if login_index <= session_limit_config.hard_limit.get() { - // Rewind time so the logins appear older than our "inactive" threshold (90 - // days) - let login_index_i64: i64 = login_index.try_into().unwrap(); - state - .clock - // Each login is a day earlier - .advance(Duration::days(-200 + login_index_i64)); - } - + let do_login = async || { let request = Request::post("/_matrix/client/v3/login").json(serde_json::json!({ "type": "m.login.password", "identifier": { @@ -1868,16 +1859,34 @@ mod tests { panic!("Expected `device_id` to be a string") } }; - login_device_ids.push(device_id); // Wait for `last_active_at` to be set state.activity_tracker.flush().await; - // Restore time - state.clock.advance(original_time - state.clock.now()); - } + // Return the new device + device_id + }; - // Sanity check that the compat sessions have `last_active_at` set. This is + // Keep logging in to add more sessions, up to the `hard_limit`. + #[allow(clippy::range_plus_one)] + for _ in 0..session_limit_config.hard_limit.get() { + let device_id = do_login().await; + login_device_ids.push(device_id); + + // Advance time so it appears like each login happens a day after each other + state.clock.advance(Duration::days(1)); + } + let time_after_past_logins = state.clock.now(); + + // Jump to "current time" (anything > INACTIVE_SESSION_THRESHOLD) which will + // make all of those past logins to be considered "inactive" at this point. + state.clock.advance(INACTIVE_SESSION_THRESHOLD.mul(2)); + assert!( + state.clock.now() - time_after_past_logins > INACTIVE_SESSION_THRESHOLD, + "Expected 'current time' login to happen > INACTIVE_SESSION_THRESHOLD from when the past logins happened" + ); + + // Sanity check that the past compat sessions have `last_active_at` set. This is // important as `last_active_at` starts out null. let mut repo = state.repository().await.unwrap(); let compat_session_page = repo @@ -1894,11 +1903,18 @@ mod tests { .last_active_at .expect("We expect compat sessions to have `last_active_at` set for this test"); assert!( - last_active_at < (state.clock.now().sub(Duration::days(90))), - "Expected compat sessions to have a `last_active_at` older than the 90 day 'inactive' threshold" + last_active_at < (state.clock.now().sub(INACTIVE_SESSION_THRESHOLD)), + "Expected past compat sessions to have a `last_active_at` older than the `INACTIVE_SESSION_THRESHOLD`" ); } + // Now the user wants to login in the "current time". + // + // One more login will drop one of our old sessions to make room for the new + // login + let device_id = do_login().await; + login_device_ids.push(device_id); + // Ensure we still only have two sessions (`session_limit_config.hard_limit`). // We're sanity checking across all session types. let session_counts = count_user_sessions_for_limiting(&mut repo, &user) From 8ab60954cfe33765926c39a432186ce8c5edf016 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Fri, 24 Apr 2026 19:12:58 -0500 Subject: [PATCH 21/31] Rename option `dangerous_hard_limit_eviction` --- crates/cli/src/util.rs | 4 +-- crates/config/src/sections/experimental.rs | 19 +++++------ crates/data-model/src/site_config.rs | 2 +- crates/handlers/src/compat/login.rs | 37 +++++++++++----------- docs/config.schema.json | 6 ++-- 5 files changed, 35 insertions(+), 33 deletions(-) diff --git a/crates/cli/src/util.rs b/crates/cli/src/util.rs index 2bf35f3c3..c3a8412f9 100644 --- a/crates/cli/src/util.rs +++ b/crates/cli/src/util.rs @@ -156,7 +156,7 @@ pub async fn policy_factory_from_config( .map(|c| SessionLimitConfig { soft_limit: c.soft_limit, hard_limit: c.hard_limit, - hard_limit_eviction: c.hard_limit_eviction, + dangerous_hard_limit_eviction: c.dangerous_hard_limit_eviction, }); let data = mas_policy::Data::new(mas_policy::BaseData { @@ -246,7 +246,7 @@ pub fn site_config_from_config( .map(|c| SessionLimitConfig { soft_limit: c.soft_limit, hard_limit: c.hard_limit, - hard_limit_eviction: c.hard_limit_eviction, + dangerous_hard_limit_eviction: c.dangerous_hard_limit_eviction, }), }) } diff --git a/crates/config/src/sections/experimental.rs b/crates/config/src/sections/experimental.rs index a0882c1cb..6806ec760 100644 --- a/crates/config/src/sections/experimental.rs +++ b/crates/config/src/sections/experimental.rs @@ -153,13 +153,14 @@ pub struct SessionLimitConfig { /// /// [`hard_limit`]: Self::hard_limit pub soft_limit: NonZeroU64, - /// Upon login, when `hard_limit_eviction: false`, will refuse the new login - /// (policy violation error), otherwise, see [`hard_limit_eviction`]. + /// Upon login, when `dangerous_hard_limit_eviction: false`, will refuse the + /// new login (policy violation error), otherwise, see + /// [`dangerous_hard_limit_eviction`]. /// /// The hard limit is enforced in all contexts /// (interactive/non-interactive). /// - /// [`hard_limit_eviction`]: Self::hard_limit_eviction + /// [`dangerous_hard_limit_eviction`]: Self::dangerous_hard_limit_eviction pub hard_limit: NonZeroU64, /// Whether we should automatically choose the least recently used devices /// to remove when the [`Self::hard_limit`] is reached; in order to @@ -172,7 +173,7 @@ pub struct SessionLimitConfig { /// be recovered if you have another verified active device or have a /// recovery key setup. /// - /// When using [`hard_limit_eviction`], the [`hard_limit`] must be + /// When using [`dangerous_hard_limit_eviction`], the [`hard_limit`] must be /// at least 2 to avoid catastrophically losing encrypted history and /// digital identity in pathological cases. Keep in mind this is a bare /// minimum restriction and you can still run into trouble. @@ -185,17 +186,17 @@ pub struct SessionLimitConfig { /// level of sanity with the number of devices that people can have. /// /// [`hard_limit`]: Self::hard_limit - /// [`hard_limit_eviction`]: Self::hard_limit_eviction + /// [`dangerous_hard_limit_eviction`]: Self::dangerous_hard_limit_eviction #[serde(default = "default_false")] - pub hard_limit_eviction: bool, + pub dangerous_hard_limit_eviction: bool, } impl SessionLimitConfig { fn validate(&self) -> Result<(), Box> { - // See [`SessionLimitConfig::hard_limit_eviction`] docstring - if self.hard_limit_eviction && self.hard_limit.get() < 2 { + // See [`SessionLimitConfig::dangerous_hard_limit_eviction`] docstring + if self.dangerous_hard_limit_eviction && self.hard_limit.get() < 2 { return Err(figment::error::Error::from( - "Session `hard_limit` must be at least 2 when automatic `hard_limit_eviction` is set. \ + "Session `hard_limit` must be at least 2 when automatic `dangerous_hard_limit_eviction` is set. \ See configuration docs for more info.", ).with_path("hard_limit").into()); } diff --git a/crates/data-model/src/site_config.rs b/crates/data-model/src/site_config.rs index 2c04c2438..9d164c639 100644 --- a/crates/data-model/src/site_config.rs +++ b/crates/data-model/src/site_config.rs @@ -44,7 +44,7 @@ pub struct SessionExpirationConfig { pub struct SessionLimitConfig { pub soft_limit: NonZeroU64, pub hard_limit: NonZeroU64, - pub hard_limit_eviction: bool, + pub dangerous_hard_limit_eviction: bool, } /// Random site configuration we want accessible in various places. diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index d29506372..20bd7cf1a 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -54,7 +54,8 @@ static LOGIN_COUNTER: LazyLock> = LazyLock::new(|| { const TYPE: Key = Key::from_static_str("type"); const RESULT: Key = Key::from_static_str("result"); -/// This matches the `getNinetyDaysAgo()` used in the web UI for "inactive" sessions. +/// This matches the `getNinetyDaysAgo()` used in the web UI for "inactive" +/// sessions. const INACTIVE_SESSION_THRESHOLD: chrono::TimeDelta = Duration::days(90); #[derive(Debug, Serialize)] @@ -532,9 +533,9 @@ async fn process_violations_for_compat_login( // When logging in with the compatibility API, there is no way for us to // display any web UI for people to remove devices, so we instead - // automatically remove their oldest devices (when `hard_limit_eviction` - // is configured). - if session_limit_config.hard_limit_eviction { + // automatically remove their oldest devices (when + // `dangerous_hard_limit_eviction` is configured). + if session_limit_config.dangerous_hard_limit_eviction { // Find the least recently used (LRU) compat sessions // // In the future, it may be nice to avoid sessions with @@ -652,7 +653,7 @@ async fn process_violations_for_compat_login( .device .as_ref() .map(mas_data_model::Device::as_str), - "Automatically removing compat session for user (`hard_limit_eviction`)" + "Automatically removing compat session for user (`dangerous_hard_limit_eviction`)" ); // Remove the session @@ -1705,7 +1706,7 @@ mod tests { soft_limit: NonZeroU64::new(1).unwrap(), // Some arbitrary high value (more than we login) hard_limit: NonZeroU64::new(5).unwrap(), - hard_limit_eviction: false, + dangerous_hard_limit_eviction: false, }), ..test_site_config() }, @@ -1755,7 +1756,7 @@ mod tests { soft_limit: NonZeroU64::new(1).unwrap(), // Lowest non-zero value so we don't have to login a bunch hard_limit: NonZeroU64::new(1).unwrap(), - hard_limit_eviction: false, + dangerous_hard_limit_eviction: false, }), ..test_site_config() }, @@ -1806,10 +1807,10 @@ mod tests { ); } - /// Test that the `hard_limit_eviction` will automatically drop old sessions - /// when we go over the limit + /// Test that the `dangerous_hard_limit_eviction` will automatically drop + /// old sessions when we go over the limit #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] - async fn test_hard_limit_eviction_old_compat_login(pool: PgPool) { + async fn test_dangerous_hard_limit_eviction_old_compat_login(pool: PgPool) { setup(); let state = TestState::from_pool_with_site_config( pool, @@ -1817,10 +1818,10 @@ mod tests { session_limit: Some(SessionLimitConfig { // (doesn't matter) soft_limit: NonZeroU64::new(1).unwrap(), - // Must be at least 2 when `hard_limit_eviction` + // Must be at least 2 when `dangerous_hard_limit_eviction` hard_limit: NonZeroU64::new(2).unwrap(), // Option under test - hard_limit_eviction: true, + dangerous_hard_limit_eviction: true, }), ..test_site_config() }, @@ -1972,11 +1973,11 @@ mod tests { ); } - /// Test that the `hard_limit_eviction` will automatically drop the oldest - /// sessions when we go over the limit even if all of the sessions are - /// recent. + /// Test that the `dangerous_hard_limit_eviction` will automatically drop + /// the oldest sessions when we go over the limit even if all of the + /// sessions are recent. #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] - async fn test_hard_limit_eviction_recent_compat_login(pool: PgPool) { + async fn test_dangerous_hard_limit_eviction_recent_compat_login(pool: PgPool) { setup(); let state = TestState::from_pool_with_site_config( pool, @@ -1984,10 +1985,10 @@ mod tests { session_limit: Some(SessionLimitConfig { // (doesn't matter) soft_limit: NonZeroU64::new(1).unwrap(), - // Must be at least 2 when `hard_limit_eviction` + // Must be at least 2 when `dangerous_hard_limit_eviction` hard_limit: NonZeroU64::new(2).unwrap(), // Option under test - hard_limit_eviction: true, + dangerous_hard_limit_eviction: true, }), ..test_site_config() }, diff --git a/docs/config.schema.json b/docs/config.schema.json index ab7692ead..c2c988a60 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -2904,13 +2904,13 @@ "minimum": 1 }, "hard_limit": { - "description": "Upon login, when `hard_limit_eviction: false`, will refuse the new login\n (policy violation error), otherwise, see [`hard_limit_eviction`].\n\n The hard limit is enforced in all contexts\n (interactive/non-interactive).\n\n [`hard_limit_eviction`]: Self::hard_limit_eviction", + "description": "Upon login, when `dangerous_hard_limit_eviction: false`, will refuse the\n new login (policy violation error), otherwise, see\n [`dangerous_hard_limit_eviction`].\n\n The hard limit is enforced in all contexts\n (interactive/non-interactive).\n\n [`dangerous_hard_limit_eviction`]: Self::dangerous_hard_limit_eviction", "type": "integer", "format": "uint64", "minimum": 1 }, - "hard_limit_eviction": { - "description": "Whether we should automatically choose the least recently used devices\n to remove when the [`Self::hard_limit`] is reached; in order to\n allow the new login to continue.\n\n Disabled by default\n\n WARNING: Removing sessions is a potentially damaging operation. Any\n end-to-end encrypted history on the device will be lost and can only\n be recovered if you have another verified active device or have a\n recovery key setup.\n\n When using [`hard_limit_eviction`], the [`hard_limit`] must be\n at least 2 to avoid catastrophically losing encrypted history and\n digital identity in pathological cases. Keep in mind this is a bare\n minimum restriction and you can still run into trouble.\n\n This is most applicable in scenarios where your homeserver has many\n legacy bots/scripts that login over and over (which ideally should\n be using [personal access\n tokens](https://github.com/element-hq/matrix-authentication-service/issues/4492))\n and you want to avoid breaking their operation while maintaining some\n level of sanity with the number of devices that people can have.\n\n [`hard_limit`]: Self::hard_limit\n [`hard_limit_eviction`]: Self::hard_limit_eviction", + "dangerous_hard_limit_eviction": { + "description": "Whether we should automatically choose the least recently used devices\n to remove when the [`Self::hard_limit`] is reached; in order to\n allow the new login to continue.\n\n Disabled by default\n\n WARNING: Removing sessions is a potentially damaging operation. Any\n end-to-end encrypted history on the device will be lost and can only\n be recovered if you have another verified active device or have a\n recovery key setup.\n\n When using [`dangerous_hard_limit_eviction`], the [`hard_limit`] must be\n at least 2 to avoid catastrophically losing encrypted history and\n digital identity in pathological cases. Keep in mind this is a bare\n minimum restriction and you can still run into trouble.\n\n This is most applicable in scenarios where your homeserver has many\n legacy bots/scripts that login over and over (which ideally should\n be using [personal access\n tokens](https://github.com/element-hq/matrix-authentication-service/issues/4492))\n and you want to avoid breaking their operation while maintaining some\n level of sanity with the number of devices that people can have.\n\n [`hard_limit`]: Self::hard_limit\n [`dangerous_hard_limit_eviction`]: Self::dangerous_hard_limit_eviction", "type": "boolean", "default": false } From 4f660bd9eaee81be9399dd154eed67063722a477 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 27 Apr 2026 15:13:54 -0500 Subject: [PATCH 22/31] Remove too-tight assertion around `session_limit` config when encountering violation See https://github.com/element-hq/matrix-authentication-service/pull/5607#discussion_r3119473839 --- crates/handlers/src/compat/login.rs | 287 +++++++++++++++------------- 1 file changed, 152 insertions(+), 135 deletions(-) diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 20bd7cf1a..1020f377b 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -521,149 +521,166 @@ async fn process_violations_for_compat_login( .. }, ] => { - let session_limit_config = session_limit_config - .expect("We should have a `session_limit` config if we are seeing a `TooManySessions` violation. \ - This is most likely a programming error."); - - let need_to_remove = usize::try_from(*need_to_remove).map_err(|err| { - RouteError::Internal( - anyhow::anyhow!("Unable to convert `need_to_remove` to usize: {err}").into(), - ) - })?; - - // When logging in with the compatibility API, there is no way for us to - // display any web UI for people to remove devices, so we instead - // automatically remove their oldest devices (when - // `dangerous_hard_limit_eviction` is configured). - if session_limit_config.dangerous_hard_limit_eviction { - // Find the least recently used (LRU) compat sessions - // - // In the future, it may be nice to avoid sessions with - // cryptographic state (what does that mean exactly? keys uploaded - // for device?). - // - // FIXME: We could potentially use - // `repo.compat_session().finish_bulk(...)` if it had the ability to - // limit and order. - let lru_compat_sessions = { - // TODO: In the future, instead of all of this faff, we can simply order - // by `last_active_at` - - let mut edges_to_consider = Vec::new(); - - // First, find the "inactive" sessions - // - // XXX: Since we can't order by `last_active_at` yet, we instead - // filter the list down to "inactive" sessions (`last_active_at` > - // 90 days ago) (this matches the `getNinetyDaysAgo()` used in the - // web UI for "inactive" sessions). And by the nature of - // [`mas_data_model::compat::CompatSession::id`] being a - // `Ulid` (the query is ordered by `compat_session_id`), the - // first bytes are a timestamp so we'll be getting the 'oldest - // created' sessions which is another good proxy. - let inactive_threshold_date = clock.now() - INACTIVE_SESSION_THRESHOLD; - let inactive_compat_session_page = repo - .compat_session() - .list( - CompatSessionFilter::new() - .for_user(user) - .active_only() - .with_last_active_before(inactive_threshold_date), - // We fetch a minimum of 100 sessions (more than we need in - // normal cases) so we can sort by `last_active_at` after it - // gets back from the database and can get even closer to - // removing the oldest sessions. - Pagination::first(std::cmp::max(need_to_remove, 100)), + // Normally, if we are seeing a `TooManySessions` violation, we would + // expect `session_limit_config` to be filled in but if someone created + // their own policies which emit a `TooManySessions` violation that isn't + // based on the configured `session_limit`, we could also end up here. + // + // If you're using the default policies in MAS, `session_limit_config` being + // `None` would be a programming error. + match session_limit_config { + Some(session_limit_config) => { + let need_to_remove = usize::try_from(*need_to_remove).map_err(|err| { + RouteError::Internal( + anyhow::anyhow!("Unable to convert `need_to_remove` to usize: {err}") + .into(), ) - .await?; - edges_to_consider.extend(inactive_compat_session_page.edges); + })?; - // If there aren't enough "inactive" sessions, supplement with active ones - if edges_to_consider.len() < need_to_remove { - let active_compat_session_page = repo - .compat_session() - .list( - // If we try to use - // `.with_last_active_after(inactive_threshold_date)` - // here, it will exclude all of the rows where - // `last_active_at` is null which we want to include. - CompatSessionFilter::new().for_user(user).active_only(), - // We fetch a minimum of 100 sessions (more than we need in - // normal cases) so we can sort by `last_active_at` after it - // gets back from the database and can get even closer to - // removing the oldest sessions. - Pagination::first(std::cmp::max(need_to_remove, 100)), - ) - .await?; - edges_to_consider.extend(active_compat_session_page.edges); - } + // When logging in with the compatibility API, there is no way for us to + // display any web UI for people to remove devices, so we instead + // automatically remove their oldest devices (when + // `dangerous_hard_limit_eviction` is configured). + if session_limit_config.dangerous_hard_limit_eviction { + // Find the least recently used (LRU) compat sessions + // + // In the future, it may be nice to avoid sessions with + // cryptographic state (what does that mean exactly? keys uploaded + // for device?). + // + // FIXME: We could potentially use + // `repo.compat_session().finish_bulk(...)` if it had the ability to + // limit and order. + let lru_compat_sessions = { + // TODO: In the future, instead of all of this faff, we can simply order + // by `last_active_at` - // De-duplicate the sessions across both pages - let compat_session_map = { - let mut compat_session_map = HashMap::new(); - for edge in edges_to_consider { - let (compat_session, _) = edge.node; - compat_session_map.insert(compat_session.id, compat_session); + let mut edges_to_consider = Vec::new(); + + // First, find the "inactive" sessions + // + // XXX: Since we can't order by `last_active_at` yet, we instead + // filter the list down to "inactive" sessions (`last_active_at` > + // 90 days ago) (this matches the `getNinetyDaysAgo()` used in the + // web UI for "inactive" sessions). And by the nature of + // [`mas_data_model::compat::CompatSession::id`] being a + // `Ulid` (the query is ordered by `compat_session_id`), the + // first bytes are a timestamp so we'll be getting the 'oldest + // created' sessions which is another good proxy. + let inactive_threshold_date = clock.now() - INACTIVE_SESSION_THRESHOLD; + let inactive_compat_session_page = repo + .compat_session() + .list( + CompatSessionFilter::new() + .for_user(user) + .active_only() + .with_last_active_before(inactive_threshold_date), + // We fetch a minimum of 100 sessions (more than we need in + // normal cases) so we can sort by `last_active_at` after it + // gets back from the database and can get even closer to + // removing the oldest sessions. + Pagination::first(std::cmp::max(need_to_remove, 100)), + ) + .await?; + edges_to_consider.extend(inactive_compat_session_page.edges); + + // If there aren't enough "inactive" sessions, supplement with active ones + if edges_to_consider.len() < need_to_remove { + let active_compat_session_page = repo + .compat_session() + .list( + // If we try to use + // `.with_last_active_after(inactive_threshold_date)` + // here, it will exclude all of the rows where + // `last_active_at` is null which we want to include. + CompatSessionFilter::new().for_user(user).active_only(), + // We fetch a minimum of 100 sessions (more than we need in + // normal cases) so we can sort by `last_active_at` after it + // gets back from the database and can get even closer to + // removing the oldest sessions. + Pagination::first(std::cmp::max(need_to_remove, 100)), + ) + .await?; + edges_to_consider.extend(active_compat_session_page.edges); + } + + // De-duplicate the sessions across both pages + let compat_session_map = { + let mut compat_session_map = HashMap::new(); + for edge in edges_to_consider { + let (compat_session, _) = edge.node; + compat_session_map.insert(compat_session.id, compat_session); + } + compat_session_map + }; + + // List of compat sessions sorted by `last_active_at` ascending + let sorted_compat_sessions = { + let mut compat_sessions: Vec = + compat_session_map.into_values().collect(); + // Sort by `last_active_at` (ascending) + compat_sessions.sort_by_key(|compat_session| { + ( + // We mainly care about sorting by `last_active_at` + compat_session.last_active_at, + // Tie-break based on `created_at` + compat_session.created_at, + // Tie-break based on `id` for determinism + compat_session.id, + ) + }); + compat_sessions + }; + + sorted_compat_sessions + }; + + // For now, we only automatically clean up compatibility sessions. + // If there aren't enough sessions that we could clean up, we just + // throw an error with an explanation. + if lru_compat_sessions.len() < need_to_remove { + return Err(RouteError::PolicyHardSessionLimitReached); } - compat_session_map - }; - // List of compat sessions sorted by `last_active_at` ascending - let sorted_compat_sessions = { - let mut compat_sessions: Vec = - compat_session_map.into_values().collect(); - // Sort by `last_active_at` (ascending) - compat_sessions.sort_by_key(|compat_session| { - ( - // We mainly care about sorting by `last_active_at` - compat_session.last_active_at, - // Tie-break based on `created_at` - compat_session.created_at, - // Tie-break based on `id` for determinism - compat_session.id, - ) - }); - compat_sessions - }; + // Remove the sessions (only as much as necessary, `need_to_remove`) + for compat_session in &lru_compat_sessions[0..need_to_remove] { + // Log what's happening so we have some explanation if someone asks + // + // FIXME: In the future, it would probably good to mark the reason + // down in the database for a better paper trail. + tracing::info!( + // So we can easily find logs for a given user + user_id = user.id.to_string(), + username = user.username, + // So we can easily look it up in the MAS database + compat_session_id = compat_session.id.to_string(), + // Make it easier to line up with what the user may be talking about + device_id = compat_session + .device + .as_ref() + .map(mas_data_model::Device::as_str), + "Automatically removing compat session for user (`dangerous_hard_limit_eviction`)" + ); - sorted_compat_sessions - }; - - // For now, we only automatically clean up compatibility sessions. - // If there aren't enough sessions that we could clean up, we just - // throw an error with an explanation. - if lru_compat_sessions.len() < need_to_remove { - return Err(RouteError::PolicyHardSessionLimitReached); + // Remove the session + repo.compat_session() + .finish(clock, compat_session.to_owned()) + .await?; + } + } else { + // Tell the user about the limit + return Err(RouteError::PolicyHardSessionLimitReached); + } } - - // Remove the sessions (only as much as necessary, `need_to_remove`) - for compat_session in &lru_compat_sessions[0..need_to_remove] { - // Log what's happening so we have some explanation if someone asks - // - // FIXME: In the future, it would probably good to mark the reason - // down in the database for a better paper trail. - tracing::info!( - // So we can easily find logs for a given user - user_id = user.id.to_string(), - username = user.username, - // So we can easily look it up in the MAS database - compat_session_id = compat_session.id.to_string(), - // Make it easier to line up with what the user may be talking about - device_id = compat_session - .device - .as_ref() - .map(mas_data_model::Device::as_str), - "Automatically removing compat session for user (`dangerous_hard_limit_eviction`)" - ); - - // Remove the session - repo.compat_session() - .finish(clock, compat_session.to_owned()) - .await?; + // If we got here, it means they are using their own custom policies + // which don't take into account the configured `session_limit`. + // + // We don't know the actual reason behind the policy emitting the + // violation so we just have to show a generic policy rejected page. + None => { + // FIXME: We should be exposing the violations to the user + return Err(RouteError::PolicyRejected); } - } else { - // Tell the user about the limit - return Err(RouteError::PolicyHardSessionLimitReached); } } // Nothing is wrong From 9506832343a80b93da8f1a9d80223494f00ffc46 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 27 Apr 2026 15:35:26 -0500 Subject: [PATCH 23/31] Extract logic to `find_lru_compat_sessions_flawed(...)` to make the usage more clear --- crates/handlers/src/compat/login.rs | 191 +++++++++++++++------------- 1 file changed, 103 insertions(+), 88 deletions(-) diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 1020f377b..7d295f641 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -544,96 +544,16 @@ async fn process_violations_for_compat_login( if session_limit_config.dangerous_hard_limit_eviction { // Find the least recently used (LRU) compat sessions // - // In the future, it may be nice to avoid sessions with - // cryptographic state (what does that mean exactly? keys uploaded - // for device?). + // FIXME: In the future, it would be nice to avoid sessions with + // cryptographic state (what does that mean exactly? keys + // uploaded for device?). // - // FIXME: We could potentially use - // `repo.compat_session().finish_bulk(...)` if it had the ability to - // limit and order. - let lru_compat_sessions = { - // TODO: In the future, instead of all of this faff, we can simply order - // by `last_active_at` - - let mut edges_to_consider = Vec::new(); - - // First, find the "inactive" sessions - // - // XXX: Since we can't order by `last_active_at` yet, we instead - // filter the list down to "inactive" sessions (`last_active_at` > - // 90 days ago) (this matches the `getNinetyDaysAgo()` used in the - // web UI for "inactive" sessions). And by the nature of - // [`mas_data_model::compat::CompatSession::id`] being a - // `Ulid` (the query is ordered by `compat_session_id`), the - // first bytes are a timestamp so we'll be getting the 'oldest - // created' sessions which is another good proxy. - let inactive_threshold_date = clock.now() - INACTIVE_SESSION_THRESHOLD; - let inactive_compat_session_page = repo - .compat_session() - .list( - CompatSessionFilter::new() - .for_user(user) - .active_only() - .with_last_active_before(inactive_threshold_date), - // We fetch a minimum of 100 sessions (more than we need in - // normal cases) so we can sort by `last_active_at` after it - // gets back from the database and can get even closer to - // removing the oldest sessions. - Pagination::first(std::cmp::max(need_to_remove, 100)), - ) + // FIXME: Instead of finding, then finshing, we could + // potentially use `repo.compat_session().finish_bulk(...)` if + // it had the ability to limit and order. + let lru_compat_sessions = + find_lru_compat_sessions_flawed(clock, repo, user, need_to_remove) .await?; - edges_to_consider.extend(inactive_compat_session_page.edges); - - // If there aren't enough "inactive" sessions, supplement with active ones - if edges_to_consider.len() < need_to_remove { - let active_compat_session_page = repo - .compat_session() - .list( - // If we try to use - // `.with_last_active_after(inactive_threshold_date)` - // here, it will exclude all of the rows where - // `last_active_at` is null which we want to include. - CompatSessionFilter::new().for_user(user).active_only(), - // We fetch a minimum of 100 sessions (more than we need in - // normal cases) so we can sort by `last_active_at` after it - // gets back from the database and can get even closer to - // removing the oldest sessions. - Pagination::first(std::cmp::max(need_to_remove, 100)), - ) - .await?; - edges_to_consider.extend(active_compat_session_page.edges); - } - - // De-duplicate the sessions across both pages - let compat_session_map = { - let mut compat_session_map = HashMap::new(); - for edge in edges_to_consider { - let (compat_session, _) = edge.node; - compat_session_map.insert(compat_session.id, compat_session); - } - compat_session_map - }; - - // List of compat sessions sorted by `last_active_at` ascending - let sorted_compat_sessions = { - let mut compat_sessions: Vec = - compat_session_map.into_values().collect(); - // Sort by `last_active_at` (ascending) - compat_sessions.sort_by_key(|compat_session| { - ( - // We mainly care about sorting by `last_active_at` - compat_session.last_active_at, - // Tie-break based on `created_at` - compat_session.created_at, - // Tie-break based on `id` for determinism - compat_session.id, - ) - }); - compat_sessions - }; - - sorted_compat_sessions - }; // For now, we only automatically clean up compatibility sessions. // If there aren't enough sessions that we could clean up, we just @@ -695,6 +615,101 @@ async fn process_violations_for_compat_login( Ok(()) } +/// Find the least recently used (LRU) compat sessions +/// +/// The results of this function are flawed because we can't order by `last_active_at` +/// and get an absolute sort of actually least recently used sessions. But we do a +/// pretty good job at working around the problem (see internal comments for details). +async fn find_lru_compat_sessions_flawed( + clock: &dyn Clock, + repo: &mut BoxRepository, + user: &User, + // Like a limit we this function may return more more results + num_requested: usize, +) -> Result, RouteError> { + // TODO: In the future, instead of all of this faff, we can simply order + // by `last_active_at` + + let mut edges_to_consider = Vec::new(); + + // First, find the "inactive" sessions + // + // XXX: Since we can't order by `last_active_at` yet, we instead + // filter the list down to "inactive" sessions (`last_active_at` > + // 90 days ago) (this matches the `getNinetyDaysAgo()` used in the + // web UI for "inactive" sessions). And by the nature of + // [`mas_data_model::compat::CompatSession::id`] being a + // `Ulid` (the query is ordered by `compat_session_id`), the + // first bytes are a timestamp so we'll be getting the 'oldest + // created' sessions which is another good proxy. + let inactive_threshold_date = clock.now() - INACTIVE_SESSION_THRESHOLD; + let inactive_compat_session_page = repo + .compat_session() + .list( + CompatSessionFilter::new() + .for_user(user) + .active_only() + .with_last_active_before(inactive_threshold_date), + // We fetch a minimum of 100 sessions (more than we need in + // normal cases) so we can sort by `last_active_at` after it + // gets back from the database and can get even closer to + // removing the oldest sessions. + Pagination::first(std::cmp::max(num_requested, 100)), + ) + .await?; + edges_to_consider.extend(inactive_compat_session_page.edges); + + // If there aren't enough "inactive" sessions, supplement with active ones + if edges_to_consider.len() < num_requested { + let active_compat_session_page = repo + .compat_session() + .list( + // If we try to use + // `.with_last_active_after(inactive_threshold_date)` + // here, it will exclude all of the rows where + // `last_active_at` is null which we want to include. + CompatSessionFilter::new().for_user(user).active_only(), + // We fetch a minimum of 100 sessions (more than we need in + // normal cases) so we can sort by `last_active_at` after it + // gets back from the database and can get even closer to + // removing the oldest sessions. + Pagination::first(std::cmp::max(num_requested, 100)), + ) + .await?; + edges_to_consider.extend(active_compat_session_page.edges); + } + + // De-duplicate the sessions across both pages + let compat_session_map = { + let mut compat_session_map = HashMap::new(); + for edge in edges_to_consider { + let (compat_session, _) = edge.node; + compat_session_map.insert(compat_session.id, compat_session); + } + compat_session_map + }; + + // List of compat sessions sorted by `last_active_at` ascending + let sorted_compat_sessions = { + let mut compat_sessions: Vec = + compat_session_map.into_values().collect(); + // Sort by `last_active_at` (ascending) + compat_sessions.sort_by_key(|compat_session| { + ( + // We mainly care about sorting by `last_active_at` + compat_session.last_active_at, + // Tie-break based on `created_at` + compat_session.created_at, + // Tie-break based on `id` for determinism + compat_session.id, + ) + }); + compat_sessions + }; + + Ok(sorted_compat_sessions) +} + async fn token_login( rng: &mut (dyn RngCore + Send), clock: &dyn Clock, From f17d9233a9bb66f1c7479320e9837b95f1463506 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 27 Apr 2026 15:38:56 -0500 Subject: [PATCH 24/31] Add spec reference for 'device identity key' (cryptographic state/devices) See https://github.com/element-hq/matrix-authentication-service/pull/5607#discussion_r3119473844 --- crates/handlers/src/compat/login.rs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 7d295f641..1c8e4756c 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -545,12 +545,20 @@ async fn process_violations_for_compat_login( // Find the least recently used (LRU) compat sessions // // FIXME: In the future, it would be nice to avoid sessions with - // cryptographic state (what does that mean exactly? keys - // uploaded for device?). + // cryptographic state. What does that mean exactly? Keys + // uploaded for device? The spec says this: + // > For all intents and purposes, non-cryptographic devices are + // > a completely separate concept and do not exist from the + // > perspective of the cryptography layer since they do not + // > have [device] identity keys, so it is impossible to send + // > them decryption keys. + // > + // > -- https://spec.matrix.org/v1.18/client-server-api/#recommended-client-behaviour // - // FIXME: Instead of finding, then finshing, we could - // potentially use `repo.compat_session().finish_bulk(...)` if - // it had the ability to limit and order. + // FIXME: Instead of finding, then finishing in separate steps, + // we could potentially use + // `repo.compat_session().finish_bulk(...)` if it had the + // ability to limit and order. let lru_compat_sessions = find_lru_compat_sessions_flawed(clock, repo, user, need_to_remove) .await?; From 2c716b638ac1efe29733289d9c7d7decf3dd53f8 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 27 Apr 2026 15:59:00 -0500 Subject: [PATCH 25/31] Explain `minimum_sessions_to_fetch` logic See https://github.com/element-hq/matrix-authentication-service/pull/5607#discussion_r3119473846 --- crates/handlers/src/compat/login.rs | 34 ++++++++++++++++++----------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 1c8e4756c..3c8b51eb2 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -625,9 +625,10 @@ async fn process_violations_for_compat_login( /// Find the least recently used (LRU) compat sessions /// -/// The results of this function are flawed because we can't order by `last_active_at` -/// and get an absolute sort of actually least recently used sessions. But we do a -/// pretty good job at working around the problem (see internal comments for details). +/// The results of this function are flawed (for accounts with more sessions than +/// `minimum_sessions_to_fetch`) because we can't order by `last_active_at` and get an +/// absolute sort of actually least recently used sessions. But we do a pretty good job +/// at working around the problem (see internal comments for details). async fn find_lru_compat_sessions_flawed( clock: &dyn Clock, repo: &mut BoxRepository, @@ -640,6 +641,21 @@ async fn find_lru_compat_sessions_flawed( let mut edges_to_consider = Vec::new(); + // We fetch a minimum of 2000 sessions (more than we need in normal cases) so we can + // sort by `last_active_at` after it gets back from the database and can get even + // closer to removing the true oldest sessions. + // + // The 2000 number was chosen based on < 0.001% of people on matrix.org having less + // than 2000 sessions and reasoning how much memory is reasonable to spend on this + // operation to get things right. Assuming each row is ~1 KiB (pessimistic high + // bound, see next paragraph below) we end up at 2 MiB of memory. + // + // Each item in the page is `(CompatSession, Option)` where + // `CompatSession` is 192 bytes plus a couple of strings (device name and user + // agent) (assume pessimistic 512 total bytes). And `CompatSsoLogin` which is also + // 192 bytes with a `login_token` string which should be no more than 32 bytes. + let minimum_sessions_to_fetch = 2000; + // First, find the "inactive" sessions // // XXX: Since we can't order by `last_active_at` yet, we instead @@ -658,11 +674,7 @@ async fn find_lru_compat_sessions_flawed( .for_user(user) .active_only() .with_last_active_before(inactive_threshold_date), - // We fetch a minimum of 100 sessions (more than we need in - // normal cases) so we can sort by `last_active_at` after it - // gets back from the database and can get even closer to - // removing the oldest sessions. - Pagination::first(std::cmp::max(num_requested, 100)), + Pagination::first(std::cmp::max(num_requested, minimum_sessions_to_fetch)), ) .await?; edges_to_consider.extend(inactive_compat_session_page.edges); @@ -677,11 +689,7 @@ async fn find_lru_compat_sessions_flawed( // here, it will exclude all of the rows where // `last_active_at` is null which we want to include. CompatSessionFilter::new().for_user(user).active_only(), - // We fetch a minimum of 100 sessions (more than we need in - // normal cases) so we can sort by `last_active_at` after it - // gets back from the database and can get even closer to - // removing the oldest sessions. - Pagination::first(std::cmp::max(num_requested, 100)), + Pagination::first(std::cmp::max(num_requested, minimum_sessions_to_fetch)), ) .await?; edges_to_consider.extend(active_compat_session_page.edges); From 701da035f2b04ebbe04c799945eb91952abb6a87 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 27 Apr 2026 16:45:03 -0500 Subject: [PATCH 26/31] `MINIMUM_SESSIONS_TO_FETCH` as 2160 to accomodate script that runs each hour for the 90 day inactive threshold --- crates/handlers/src/compat/login.rs | 48 +++++++++++++++++++---------- 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 3c8b51eb2..475754095 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -623,6 +623,35 @@ async fn process_violations_for_compat_login( Ok(()) } +/// We fetch a minimum number of sessions (2160, more than we need in normal +/// cases) so we can sort by `last_active_at` after it gets back from the database +/// and can get even closer to removing the true oldest sessions. +/// +/// The 2160 number was chosen based on someone having a script that runs every hour +/// for the the 90-day `INACTIVE_SESSION_THRESHOLD`. Additionally, it also aligns +/// nicely with < 0.001% of people on matrix.org having less than 2160 sessions and +/// reasoning how much memory is reasonable to spend on this operation to get things +/// right. Assuming each row is ~1 KiB (pessimistic high bound, see next paragraph +/// below) we end up at ~2 MiB of memory. +/// +/// Each item in the page is `(CompatSession, Option)` where +/// `CompatSession` is 192 bytes plus a couple of strings (device name and user +/// agent) (assume pessimistic 512 total bytes). And `CompatSsoLogin` which is also +/// 192 bytes with a `login_token` string which should be no more than 32 bytes. +const MINIMUM_SESSIONS_TO_FETCH: usize = { + let min_sessions = INACTIVE_SESSION_THRESHOLD.num_days() * 24; + // Ideally, we'd use `usize::try_from(min_sessions)` but that doesn't work in const + // contexts. + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + { + assert!( + min_sessions >= 0, + "`INACTIVE_SESSION_THRESHOLD` must be non-negative (we want to convert to a usize)" + ); + min_sessions as usize + } +}; + /// Find the least recently used (LRU) compat sessions /// /// The results of this function are flawed (for accounts with more sessions than @@ -641,21 +670,6 @@ async fn find_lru_compat_sessions_flawed( let mut edges_to_consider = Vec::new(); - // We fetch a minimum of 2000 sessions (more than we need in normal cases) so we can - // sort by `last_active_at` after it gets back from the database and can get even - // closer to removing the true oldest sessions. - // - // The 2000 number was chosen based on < 0.001% of people on matrix.org having less - // than 2000 sessions and reasoning how much memory is reasonable to spend on this - // operation to get things right. Assuming each row is ~1 KiB (pessimistic high - // bound, see next paragraph below) we end up at 2 MiB of memory. - // - // Each item in the page is `(CompatSession, Option)` where - // `CompatSession` is 192 bytes plus a couple of strings (device name and user - // agent) (assume pessimistic 512 total bytes). And `CompatSsoLogin` which is also - // 192 bytes with a `login_token` string which should be no more than 32 bytes. - let minimum_sessions_to_fetch = 2000; - // First, find the "inactive" sessions // // XXX: Since we can't order by `last_active_at` yet, we instead @@ -674,7 +688,7 @@ async fn find_lru_compat_sessions_flawed( .for_user(user) .active_only() .with_last_active_before(inactive_threshold_date), - Pagination::first(std::cmp::max(num_requested, minimum_sessions_to_fetch)), + Pagination::first(std::cmp::max(num_requested, MINIMUM_SESSIONS_TO_FETCH)), ) .await?; edges_to_consider.extend(inactive_compat_session_page.edges); @@ -689,7 +703,7 @@ async fn find_lru_compat_sessions_flawed( // here, it will exclude all of the rows where // `last_active_at` is null which we want to include. CompatSessionFilter::new().for_user(user).active_only(), - Pagination::first(std::cmp::max(num_requested, minimum_sessions_to_fetch)), + Pagination::first(std::cmp::max(num_requested, MINIMUM_SESSIONS_TO_FETCH)), ) .await?; edges_to_consider.extend(active_compat_session_page.edges); From dacbf902242effe5630de5ada1ca8ff4c65e4aba Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 27 Apr 2026 17:00:37 -0500 Subject: [PATCH 27/31] Also const assert how big `MINIMUM_SESSIONS_TO_FETCH` can be --- crates/handlers/src/compat/login.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 475754095..eef42d41c 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -651,6 +651,17 @@ const MINIMUM_SESSIONS_TO_FETCH: usize = { min_sessions as usize } }; +// This is a stop-gap to make people think about the downstream effects of updating +// `INACTIVE_SESSION_THRESHOLD` or whatever contributing factors go into +// `MINIMUM_SESSIONS_TO_FETCH`. +const _: () = { + assert!( + // Update this value if you're ok with the ammount of memory that could be used. + MINIMUM_SESSIONS_TO_FETCH == 2160, + "Sanity check that you're okay with `MINIMUM_SESSIONS_TO_FETCH` x 1 KiB when fetching sessions? \ + (read the `MINIMUM_SESSIONS_TO_FETCH` docstring)" + ); +}; /// Find the least recently used (LRU) compat sessions /// From b2d7ef9583bdcd739275e65365cdcd5100701bc3 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 27 Apr 2026 17:44:40 -0500 Subject: [PATCH 28/31] Better clarify `MINIMUM_SESSIONS_TO_FETCH` asserts --- crates/handlers/src/compat/login.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index eef42d41c..79ba7541a 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -642,12 +642,18 @@ const MINIMUM_SESSIONS_TO_FETCH: usize = { let min_sessions = INACTIVE_SESSION_THRESHOLD.num_days() * 24; // Ideally, we'd use `usize::try_from(min_sessions)` but that doesn't work in const // contexts. - #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] { + // Sanity check that `clippy::cast_sign_loss` doesn't apply assert!( min_sessions >= 0, - "`INACTIVE_SESSION_THRESHOLD` must be non-negative (we want to convert to a usize)" + "`MINIMUM_SESSIONS_TO_FETCH` must be non-negative (we want to convert to a usize)" ); + // For `clippy::cast_possible_truncation`, we're going to assume that someone + // doesn't specify some value bigger than can fit in the `usize`. On a 16-bit + // platform, that would be 65,535 days. + + // Based on the above asserts, we can assume that that the cast is safe min_sessions as usize } }; From caf3d97f5ee94faaea0ccb57c2d9436b7e2b35ca Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 27 Apr 2026 17:46:28 -0500 Subject: [PATCH 29/31] Fix lints --- crates/handlers/src/compat/login.rs | 43 +++++++++++++++-------------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 79ba7541a..5c4e72105 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -582,7 +582,8 @@ async fn process_violations_for_compat_login( username = user.username, // So we can easily look it up in the MAS database compat_session_id = compat_session.id.to_string(), - // Make it easier to line up with what the user may be talking about + // Make it easier to line up with what the user may be talking + // about device_id = compat_session .device .as_ref() @@ -624,24 +625,25 @@ async fn process_violations_for_compat_login( } /// We fetch a minimum number of sessions (2160, more than we need in normal -/// cases) so we can sort by `last_active_at` after it gets back from the database -/// and can get even closer to removing the true oldest sessions. +/// cases) so we can sort by `last_active_at` after it gets back from the +/// database and can get even closer to removing the true oldest sessions. /// -/// The 2160 number was chosen based on someone having a script that runs every hour -/// for the the 90-day `INACTIVE_SESSION_THRESHOLD`. Additionally, it also aligns -/// nicely with < 0.001% of people on matrix.org having less than 2160 sessions and -/// reasoning how much memory is reasonable to spend on this operation to get things -/// right. Assuming each row is ~1 KiB (pessimistic high bound, see next paragraph -/// below) we end up at ~2 MiB of memory. +/// The 2160 number was chosen based on someone having a script that runs every +/// hour for the the 90-day `INACTIVE_SESSION_THRESHOLD`. Additionally, it also +/// aligns nicely with < 0.001% of people on matrix.org having less than 2160 +/// sessions and reasoning how much memory is reasonable to spend on this +/// operation to get things right. Assuming each row is ~1 KiB (pessimistic high +/// bound, see next paragraph below) we end up at ~2 MiB of memory. /// /// Each item in the page is `(CompatSession, Option)` where /// `CompatSession` is 192 bytes plus a couple of strings (device name and user -/// agent) (assume pessimistic 512 total bytes). And `CompatSsoLogin` which is also -/// 192 bytes with a `login_token` string which should be no more than 32 bytes. +/// agent) (assume pessimistic 512 total bytes). And `CompatSsoLogin` which is +/// also 192 bytes with a `login_token` string which should be no more than 32 +/// bytes. const MINIMUM_SESSIONS_TO_FETCH: usize = { let min_sessions = INACTIVE_SESSION_THRESHOLD.num_days() * 24; - // Ideally, we'd use `usize::try_from(min_sessions)` but that doesn't work in const - // contexts. + // Ideally, we'd use `usize::try_from(min_sessions)` but that doesn't work in + // const contexts. #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] { // Sanity check that `clippy::cast_sign_loss` doesn't apply @@ -657,9 +659,9 @@ const MINIMUM_SESSIONS_TO_FETCH: usize = { min_sessions as usize } }; -// This is a stop-gap to make people think about the downstream effects of updating -// `INACTIVE_SESSION_THRESHOLD` or whatever contributing factors go into -// `MINIMUM_SESSIONS_TO_FETCH`. +// This is a stop-gap to make people think about the downstream effects of +// updating `INACTIVE_SESSION_THRESHOLD` or whatever contributing factors go +// into `MINIMUM_SESSIONS_TO_FETCH`. const _: () = { assert!( // Update this value if you're ok with the ammount of memory that could be used. @@ -671,10 +673,11 @@ const _: () = { /// Find the least recently used (LRU) compat sessions /// -/// The results of this function are flawed (for accounts with more sessions than -/// `minimum_sessions_to_fetch`) because we can't order by `last_active_at` and get an -/// absolute sort of actually least recently used sessions. But we do a pretty good job -/// at working around the problem (see internal comments for details). +/// The results of this function are flawed (for accounts with more sessions +/// than `minimum_sessions_to_fetch`) because we can't order by `last_active_at` +/// and get an absolute sort of actually least recently used sessions. But we do +/// a pretty good job at working around the problem (see internal comments for +/// details). async fn find_lru_compat_sessions_flawed( clock: &dyn Clock, repo: &mut BoxRepository, From 2c80015fc99c1d9f88410aee2b624e40d04e5176 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 27 Apr 2026 17:53:32 -0500 Subject: [PATCH 30/31] Remove `MINIMUM_SESSIONS_TO_FETCH` complexity See https://github.com/element-hq/matrix-authentication-service/pull/5607#discussion_r3150594429 --- crates/handlers/src/compat/login.rs | 31 +---------------------------- 1 file changed, 1 insertion(+), 30 deletions(-) diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 5c4e72105..6b0d26814 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -640,36 +640,7 @@ async fn process_violations_for_compat_login( /// agent) (assume pessimistic 512 total bytes). And `CompatSsoLogin` which is /// also 192 bytes with a `login_token` string which should be no more than 32 /// bytes. -const MINIMUM_SESSIONS_TO_FETCH: usize = { - let min_sessions = INACTIVE_SESSION_THRESHOLD.num_days() * 24; - // Ideally, we'd use `usize::try_from(min_sessions)` but that doesn't work in - // const contexts. - #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] - { - // Sanity check that `clippy::cast_sign_loss` doesn't apply - assert!( - min_sessions >= 0, - "`MINIMUM_SESSIONS_TO_FETCH` must be non-negative (we want to convert to a usize)" - ); - // For `clippy::cast_possible_truncation`, we're going to assume that someone - // doesn't specify some value bigger than can fit in the `usize`. On a 16-bit - // platform, that would be 65,535 days. - - // Based on the above asserts, we can assume that that the cast is safe - min_sessions as usize - } -}; -// This is a stop-gap to make people think about the downstream effects of -// updating `INACTIVE_SESSION_THRESHOLD` or whatever contributing factors go -// into `MINIMUM_SESSIONS_TO_FETCH`. -const _: () = { - assert!( - // Update this value if you're ok with the ammount of memory that could be used. - MINIMUM_SESSIONS_TO_FETCH == 2160, - "Sanity check that you're okay with `MINIMUM_SESSIONS_TO_FETCH` x 1 KiB when fetching sessions? \ - (read the `MINIMUM_SESSIONS_TO_FETCH` docstring)" - ); -}; +const MINIMUM_SESSIONS_TO_FETCH: usize = 2160; /// Find the least recently used (LRU) compat sessions /// From e9165887a1414971812c21338df81d86b5692972 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 27 Apr 2026 17:53:56 -0500 Subject: [PATCH 31/31] Reference actual const in comment --- crates/handlers/src/compat/login.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 6b0d26814..e36e57a5b 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -645,7 +645,7 @@ const MINIMUM_SESSIONS_TO_FETCH: usize = 2160; /// Find the least recently used (LRU) compat sessions /// /// The results of this function are flawed (for accounts with more sessions -/// than `minimum_sessions_to_fetch`) because we can't order by `last_active_at` +/// than `MINIMUM_SESSIONS_TO_FETCH`) because we can't order by `last_active_at` /// and get an absolute sort of actually least recently used sessions. But we do /// a pretty good job at working around the problem (see internal comments for /// details).