diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index 2e787e86..253ec029 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -767,6 +767,12 @@ export interface SessionSummary { dpop_jkt_prefix: string | null; is_revoked: boolean; is_active: boolean; + /** How this session was minted. `unknown` covers pre-migration + * rows and any origin the SPA doesn't yet render. Server enum + * is populated at INSERT (see `Session::new`) and copied on + * refresh. Snake_case wire values map to the labels rendered + * in the admin table. */ + origin: 'password' | 'opaque' | 'magic_link' | 'oidc' | 'device' | 'unknown'; /** `true` when this row IS the admin's currently-active session — * compared server-side by `dpop_jkt`. Panel uses this to warn * before revoking ("this will log you out"). Always `false` when @@ -779,4 +785,9 @@ export interface AdminSessionsPage { sessions: SessionSummary[]; limit: number; offset: number; + /** Access-token TTL in seconds — from `OXICLOUD_ACCESS_TOKEN_EXPIRY_SECS` + * server-side. The panel surfaces this in a "revoke takes effect within + * {N} seconds" notice because revoking flips the DB row (breaks refresh) + * but any in-flight JWT stays valid until its `exp`. */ + access_token_expiry_secs: number; } diff --git a/frontend/src/routes/admin/[[tab]]/+page.svelte b/frontend/src/routes/admin/[[tab]]/+page.svelte index f1c7c29e..60633dda 100644 --- a/frontend/src/routes/admin/[[tab]]/+page.svelte +++ b/frontend/src/routes/admin/[[tab]]/+page.svelte @@ -861,6 +861,12 @@ let sessionsFilterUserId = $state(''); let sessionsIncludeRevoked = $state(false); let sessionRevokingId = $state(null); + // Access-token TTL served alongside the sessions page — drives the + // "revoke takes effect within {N} seconds" warning. Revoke flips the + // DB row (breaks the refresh path), but a JWT already in flight + // stays valid until its `exp`. Populated on the first load, reused + // for every render — the value is server-config, not per-request. + let sessionsAccessTokenExpirySecs = $state(null); async function loadSessions() { sessionsLoading = true; @@ -872,6 +878,7 @@ limit: PAGE_SIZE }); sessions = page.sessions; + sessionsAccessTokenExpirySecs = page.access_token_expiry_secs; } catch (e) { sessionsError = errorMessage(e); } finally { @@ -2996,10 +3003,26 @@ {#if sessionsError}

{sessionsError}

{:else} + {#if sessionsAccessTokenExpirySecs !== null} + +

+ {t( + 'admin.sessions.revoke_lag_notice', + { secs: sessionsAccessTokenExpirySecs }, + 'Revoking a session breaks its refresh path immediately, but any JWT already in the browser stays valid for up to {{secs}} seconds until the next refresh attempt.' + )} +

+ {/if} + @@ -3032,6 +3055,11 @@ {/if} + @@ -3088,7 +3116,7 @@ {/each} {#if sessions.length === 0 && !sessionsLoading} - diff --git a/frontend/static/locales/ar.json b/frontend/static/locales/ar.json index 1a6c1bf4..557b3625 100644 --- a/frontend/static/locales/ar.json +++ b/frontend/static/locales/ar.json @@ -848,6 +848,7 @@ "include_revoked": "تضمين الملغاة / المنتهية", "refresh": "تحديث", "col_user": "المستخدم", + "col_origin": "المصدر", "col_created": "تم الإنشاء", "col_expires": "تنتهي في", "col_ip": "IP", @@ -863,7 +864,16 @@ "revoke": "إلغاء", "empty": "لا توجد جلسات مطابقة للتصفية الحالية.", "revoke_self_confirm": "⚠️ هذه جلستك الحالية. إلغاؤها سيؤدي إلى تسجيل خروجك فورًا وعليك تسجيل الدخول مرة أخرى. هل تريد المتابعة؟", - "revoke_confirm": "إلغاء هذه الجلسة؟ سيتلقى المتصفح رمز 401 عند الطلب التالي." + "revoke_confirm": "إلغاء هذه الجلسة؟ سيتلقى المتصفح رمز 401 عند الطلب التالي.", + "revoke_lag_notice": "إلغاء الجلسة يقطع مسار التحديث فورًا، لكن أي JWT موجود بالفعل في المتصفح يظل صالحًا حتى {{secs}} ثانية حتى محاولة التحديث التالية.", + "origin": { + "password": "كلمة المرور", + "opaque": "OPAQUE", + "magic_link": "رابط سحري", + "oidc": "SSO", + "device": "جهاز", + "unknown": "غير معروف" + } }, "smtp_fail": "فشل الإرسال.", "smtp_send": "إرسال", diff --git a/frontend/static/locales/de.json b/frontend/static/locales/de.json index 53eb5701..f1b61a7e 100644 --- a/frontend/static/locales/de.json +++ b/frontend/static/locales/de.json @@ -848,6 +848,7 @@ "include_revoked": "Widerrufene / abgelaufene einschließen", "refresh": "Aktualisieren", "col_user": "Benutzer", + "col_origin": "Herkunft", "col_created": "Erstellt", "col_expires": "Läuft ab", "col_ip": "IP", @@ -863,7 +864,16 @@ "revoke": "Widerrufen", "empty": "Keine Sitzungen entsprechen dem aktuellen Filter.", "revoke_self_confirm": "⚠️ Dies ist IHRE aktuelle Sitzung. Wenn Sie sie widerrufen, werden Sie sofort abgemeldet und müssen sich neu anmelden. Fortfahren?", - "revoke_confirm": "Diese Sitzung widerrufen? Die nächste Anfrage von diesem Browser erhält 401." + "revoke_confirm": "Diese Sitzung widerrufen? Die nächste Anfrage von diesem Browser erhält 401.", + "revoke_lag_notice": "Das Widerrufen einer Sitzung unterbricht den Refresh-Pfad sofort, aber ein bereits im Browser vorhandenes JWT bleibt bis zu {{secs}} Sekunden gültig, bis der nächste Refresh-Versuch erfolgt.", + "origin": { + "password": "Passwort", + "opaque": "OPAQUE", + "magic_link": "Magic Link", + "oidc": "SSO", + "device": "Gerät", + "unknown": "Unbekannt" + } }, "smtp_fail": "Senden fehlgeschlagen.", "smtp_send": "Senden", diff --git a/frontend/static/locales/en.json b/frontend/static/locales/en.json index 4403ad63..dd3ee659 100644 --- a/frontend/static/locales/en.json +++ b/frontend/static/locales/en.json @@ -1120,6 +1120,7 @@ "include_revoked": "Include revoked / expired", "refresh": "Refresh", "col_user": "User", + "col_origin": "Origin", "col_created": "Created", "col_expires": "Expires", "col_ip": "IP", @@ -1135,7 +1136,16 @@ "revoke": "Revoke", "empty": "No sessions match the current filter.", "revoke_self_confirm": "⚠️ This is YOUR current session. Revoking it will log YOU out immediately and you'll have to sign back in. Continue?", - "revoke_confirm": "Revoke this session? The next request from that browser will 401." + "revoke_confirm": "Revoke this session? The next request from that browser will 401.", + "revoke_lag_notice": "Revoking a session breaks its refresh path immediately, but any JWT already in the browser stays valid for up to {{secs}} seconds until the next refresh attempt.", + "origin": { + "password": "Password", + "opaque": "OPAQUE", + "magic_link": "Magic link", + "oidc": "SSO", + "device": "Device", + "unknown": "Unknown" + } }, "settings_saved_ok": "Settings saved.", "smtp": "Email (SMTP)", diff --git a/frontend/static/locales/es.json b/frontend/static/locales/es.json index a2bf1b0b..ef57b7d8 100644 --- a/frontend/static/locales/es.json +++ b/frontend/static/locales/es.json @@ -853,6 +853,7 @@ "include_revoked": "Incluir revocadas / caducadas", "refresh": "Actualizar", "col_user": "Usuario", + "col_origin": "Origen", "col_created": "Creada", "col_expires": "Caduca", "col_ip": "IP", @@ -868,7 +869,16 @@ "revoke": "Revocar", "empty": "Ninguna sesión coincide con el filtro actual.", "revoke_self_confirm": "⚠️ Esta es SU sesión actual. Al revocarla se cerrará su sesión inmediatamente y tendrá que iniciar sesión de nuevo. ¿Continuar?", - "revoke_confirm": "¿Revocar esta sesión? La próxima petición desde ese navegador devolverá 401." + "revoke_confirm": "¿Revocar esta sesión? La próxima petición desde ese navegador devolverá 401.", + "revoke_lag_notice": "Revocar una sesión rompe su vía de renovación de inmediato, pero cualquier JWT ya presente en el navegador permanece válido hasta {{secs}} segundos, hasta el próximo intento de renovación.", + "origin": { + "password": "Contraseña", + "opaque": "OPAQUE", + "magic_link": "Enlace mágico", + "oidc": "SSO", + "device": "Dispositivo", + "unknown": "Desconocido" + } }, "smtp_fail": "Fallo al enviar.", "smtp_send": "Enviar", diff --git a/frontend/static/locales/fa.json b/frontend/static/locales/fa.json index fb1fbab5..c8647770 100644 --- a/frontend/static/locales/fa.json +++ b/frontend/static/locales/fa.json @@ -831,6 +831,7 @@ "include_revoked": "شامل لغو‌شده‌ها / منقضی‌شده‌ها", "refresh": "به‌روزرسانی", "col_user": "کاربر", + "col_origin": "منبع", "col_created": "ایجاد شده", "col_expires": "انقضا", "col_ip": "IP", @@ -846,7 +847,16 @@ "revoke": "لغو", "empty": "هیچ نشستی با فیلتر فعلی مطابقت ندارد.", "revoke_self_confirm": "⚠️ این نشست فعلی شماست. با لغو آن، بلافاصله خارج می‌شوید و باید دوباره وارد شوید. ادامه؟", - "revoke_confirm": "این نشست لغو شود؟ درخواست بعدی از آن مرورگر با 401 پاسخ داده می‌شود." + "revoke_confirm": "این نشست لغو شود؟ درخواست بعدی از آن مرورگر با 401 پاسخ داده می‌شود.", + "revoke_lag_notice": "لغو یک نشست بلافاصله مسیر تازه‌سازی را قطع می‌کند، اما هر JWT که از قبل در مرورگر وجود دارد تا {{secs}} ثانیه تا تلاش بعدی برای تازه‌سازی معتبر باقی می‌ماند.", + "origin": { + "password": "رمز عبور", + "opaque": "OPAQUE", + "magic_link": "پیوند جادویی", + "oidc": "SSO", + "device": "دستگاه", + "unknown": "ناشناخته" + } }, "smtp_fail": "ارسال ناموفق.", "smtp_send": "ارسال", diff --git a/frontend/static/locales/fr.json b/frontend/static/locales/fr.json index 6b980ebf..a3279ad3 100644 --- a/frontend/static/locales/fr.json +++ b/frontend/static/locales/fr.json @@ -859,6 +859,7 @@ "include_revoked": "Inclure les révoquées / expirées", "refresh": "Actualiser", "col_user": "Utilisateur", + "col_origin": "Origine", "col_created": "Créée", "col_expires": "Expire", "col_ip": "IP", @@ -874,7 +875,16 @@ "revoke": "Révoquer", "empty": "Aucune session ne correspond au filtre actuel.", "revoke_self_confirm": "⚠️ Il s'agit de VOTRE session actuelle. La révoquer vous déconnectera immédiatement et vous devrez vous reconnecter. Continuer ?", - "revoke_confirm": "Révoquer cette session ? La prochaine requête depuis ce navigateur renverra 401." + "revoke_confirm": "Révoquer cette session ? La prochaine requête depuis ce navigateur renverra 401.", + "revoke_lag_notice": "La révocation d'une session coupe immédiatement son chemin de rafraîchissement, mais tout JWT déjà présent dans le navigateur reste valide pendant jusqu'à {{secs}} secondes, jusqu'à la prochaine tentative de rafraîchissement.", + "origin": { + "password": "Mot de passe", + "opaque": "OPAQUE", + "magic_link": "Lien magique", + "oidc": "SSO", + "device": "Appareil", + "unknown": "Inconnu" + } }, "smtp_fail": "Échec de l'envoi.", "smtp_send": "Envoyer", diff --git a/frontend/static/locales/hi.json b/frontend/static/locales/hi.json index fbd3c38d..329330f9 100644 --- a/frontend/static/locales/hi.json +++ b/frontend/static/locales/hi.json @@ -848,6 +848,7 @@ "include_revoked": "रद्द / समाप्त शामिल करें", "refresh": "ताज़ा करें", "col_user": "उपयोगकर्ता", + "col_origin": "स्रोत", "col_created": "बनाई गई", "col_expires": "समाप्त होगी", "col_ip": "IP", @@ -863,7 +864,16 @@ "revoke": "रद्द करें", "empty": "वर्तमान फ़िल्टर से कोई सत्र मेल नहीं खाता।", "revoke_self_confirm": "⚠️ यह आपका वर्तमान सत्र है। इसे रद्द करने पर आप तुरंत साइन आउट हो जाएंगे और आपको दोबारा साइन इन करना होगा। जारी रखें?", - "revoke_confirm": "इस सत्र को रद्द करें? उस ब्राउज़र से अगला अनुरोध 401 होगा।" + "revoke_confirm": "इस सत्र को रद्द करें? उस ब्राउज़र से अगला अनुरोध 401 होगा।", + "revoke_lag_notice": "किसी सत्र को रद्द करने से उसका रीफ़्रेश पथ तुरंत टूट जाता है, लेकिन ब्राउज़र में पहले से मौजूद कोई भी JWT अगले रीफ़्रेश प्रयास तक {{secs}} सेकंड तक मान्य रहता है।", + "origin": { + "password": "पासवर्ड", + "opaque": "OPAQUE", + "magic_link": "मैजिक लिंक", + "oidc": "SSO", + "device": "डिवाइस", + "unknown": "अज्ञात" + } }, "smtp_fail": "भेजना विफल।", "smtp_send": "भेजें", diff --git a/frontend/static/locales/it.json b/frontend/static/locales/it.json index 5cefa8f9..95b0e61a 100644 --- a/frontend/static/locales/it.json +++ b/frontend/static/locales/it.json @@ -848,6 +848,7 @@ "include_revoked": "Includi revocate / scadute", "refresh": "Aggiorna", "col_user": "Utente", + "col_origin": "Origine", "col_created": "Creata", "col_expires": "Scade", "col_ip": "IP", @@ -863,7 +864,16 @@ "revoke": "Revoca", "empty": "Nessuna sessione corrisponde al filtro attuale.", "revoke_self_confirm": "⚠️ Questa è la TUA sessione corrente. Revocarla ti disconnetterà immediatamente e dovrai accedere di nuovo. Continuare?", - "revoke_confirm": "Revocare questa sessione? La prossima richiesta da quel browser risponderà 401." + "revoke_confirm": "Revocare questa sessione? La prossima richiesta da quel browser risponderà 401.", + "revoke_lag_notice": "Revocare una sessione interrompe immediatamente il suo percorso di aggiornamento, ma qualsiasi JWT già presente nel browser rimane valido fino a {{secs}} secondi, fino al prossimo tentativo di aggiornamento.", + "origin": { + "password": "Password", + "opaque": "OPAQUE", + "magic_link": "Link magico", + "oidc": "SSO", + "device": "Dispositivo", + "unknown": "Sconosciuto" + } }, "smtp_fail": "Invio non riuscito.", "smtp_send": "Invia", diff --git a/frontend/static/locales/ja.json b/frontend/static/locales/ja.json index 8ee1b2bb..7893fcd7 100644 --- a/frontend/static/locales/ja.json +++ b/frontend/static/locales/ja.json @@ -848,6 +848,7 @@ "include_revoked": "取り消し済み / 期限切れを含む", "refresh": "更新", "col_user": "ユーザー", + "col_origin": "由来", "col_created": "作成日時", "col_expires": "有効期限", "col_ip": "IP", @@ -863,7 +864,16 @@ "revoke": "取り消し", "empty": "現在のフィルターに一致するセッションはありません。", "revoke_self_confirm": "⚠️ これはあなたの現在のセッションです。取り消すと直ちにサインアウトされ、再度サインインが必要になります。続行しますか?", - "revoke_confirm": "このセッションを取り消しますか? そのブラウザからの次のリクエストは 401 になります。" + "revoke_confirm": "このセッションを取り消しますか? そのブラウザからの次のリクエストは 401 になります。", + "revoke_lag_notice": "セッションを取り消すと、リフレッシュ経路は直ちに切断されますが、ブラウザにすでに存在する JWT は次のリフレッシュ試行まで最大 {{secs}} 秒間有効なままです。", + "origin": { + "password": "パスワード", + "opaque": "OPAQUE", + "magic_link": "マジックリンク", + "oidc": "SSO", + "device": "デバイス", + "unknown": "不明" + } }, "smtp_fail": "送信に失敗しました。", "smtp_send": "送信", diff --git a/frontend/static/locales/ko.json b/frontend/static/locales/ko.json index 1f0f9ede..7c852d50 100644 --- a/frontend/static/locales/ko.json +++ b/frontend/static/locales/ko.json @@ -975,6 +975,7 @@ "include_revoked": "취소됨 / 만료됨 포함", "refresh": "새로 고침", "col_user": "사용자", + "col_origin": "출처", "col_created": "생성일", "col_expires": "만료일", "col_ip": "IP", @@ -990,7 +991,16 @@ "revoke": "취소", "empty": "현재 필터와 일치하는 세션이 없습니다.", "revoke_self_confirm": "⚠️ 현재 사용 중인 세션입니다. 취소하면 즉시 로그아웃되고 다시 로그인해야 합니다. 계속하시겠습니까?", - "revoke_confirm": "이 세션을 취소하시겠습니까? 해당 브라우저의 다음 요청은 401을 받게 됩니다." + "revoke_confirm": "이 세션을 취소하시겠습니까? 해당 브라우저의 다음 요청은 401을 받게 됩니다.", + "revoke_lag_notice": "세션을 취소하면 새로 고침 경로가 즉시 끊어지지만, 브라우저에 이미 있는 JWT는 다음 새로 고침 시도까지 최대 {{secs}}초 동안 유효합니다.", + "origin": { + "password": "비밀번호", + "opaque": "OPAQUE", + "magic_link": "매직 링크", + "oidc": "SSO", + "device": "장치", + "unknown": "알 수 없음" + } }, "smtp_fail": "전송 실패.", "smtp_send": "보내기", diff --git a/frontend/static/locales/nl.json b/frontend/static/locales/nl.json index bf60deba..faf17de2 100644 --- a/frontend/static/locales/nl.json +++ b/frontend/static/locales/nl.json @@ -848,6 +848,7 @@ "include_revoked": "Ingetrokken / verlopen tonen", "refresh": "Vernieuwen", "col_user": "Gebruiker", + "col_origin": "Herkomst", "col_created": "Aangemaakt", "col_expires": "Verloopt", "col_ip": "IP", @@ -863,7 +864,16 @@ "revoke": "Intrekken", "empty": "Geen sessies komen overeen met het huidige filter.", "revoke_self_confirm": "⚠️ Dit is UW huidige sessie. Intrekken meldt u direct af en u moet opnieuw inloggen. Doorgaan?", - "revoke_confirm": "Deze sessie intrekken? Het volgende verzoek van die browser krijgt 401." + "revoke_confirm": "Deze sessie intrekken? Het volgende verzoek van die browser krijgt 401.", + "revoke_lag_notice": "Het intrekken van een sessie verbreekt onmiddellijk het vernieuwingspad, maar elke JWT die al in de browser aanwezig is, blijft geldig tot {{secs}} seconden, tot de volgende vernieuwingspoging.", + "origin": { + "password": "Wachtwoord", + "opaque": "OPAQUE", + "magic_link": "Magische link", + "oidc": "SSO", + "device": "Apparaat", + "unknown": "Onbekend" + } }, "smtp_fail": "Verzenden mislukt.", "smtp_send": "Verzenden", diff --git a/frontend/static/locales/pl.json b/frontend/static/locales/pl.json index f30660d0..a9a7da4a 100644 --- a/frontend/static/locales/pl.json +++ b/frontend/static/locales/pl.json @@ -848,6 +848,7 @@ "include_revoked": "Uwzględnij unieważnione / wygasłe", "refresh": "Odśwież", "col_user": "Użytkownik", + "col_origin": "Pochodzenie", "col_created": "Utworzono", "col_expires": "Wygasa", "col_ip": "IP", @@ -863,7 +864,16 @@ "revoke": "Unieważnij", "empty": "Żadne sesje nie pasują do bieżącego filtra.", "revoke_self_confirm": "⚠️ To TWOJA bieżąca sesja. Unieważnienie jej wyloguje Cię natychmiast i musisz zalogować się ponownie. Kontynuować?", - "revoke_confirm": "Unieważnić tę sesję? Następne żądanie z tej przeglądarki otrzyma 401." + "revoke_confirm": "Unieważnić tę sesję? Następne żądanie z tej przeglądarki otrzyma 401.", + "revoke_lag_notice": "Unieważnienie sesji natychmiast przerywa ścieżkę odświeżania, ale każdy JWT już obecny w przeglądarce pozostaje ważny przez maksymalnie {{secs}} sekund, aż do następnej próby odświeżenia.", + "origin": { + "password": "Hasło", + "opaque": "OPAQUE", + "magic_link": "Magiczny link", + "oidc": "SSO", + "device": "Urządzenie", + "unknown": "Nieznane" + } }, "smtp_fail": "Wysłanie nie powiodło się.", "smtp_send": "Wyślij", diff --git a/frontend/static/locales/pt.json b/frontend/static/locales/pt.json index 5c395bac..055fc06a 100644 --- a/frontend/static/locales/pt.json +++ b/frontend/static/locales/pt.json @@ -848,6 +848,7 @@ "include_revoked": "Incluir revogadas / expiradas", "refresh": "Atualizar", "col_user": "Utilizador", + "col_origin": "Origem", "col_created": "Criada", "col_expires": "Expira", "col_ip": "IP", @@ -863,7 +864,16 @@ "revoke": "Revogar", "empty": "Nenhuma sessão corresponde ao filtro atual.", "revoke_self_confirm": "⚠️ Esta é a SUA sessão atual. Revogá-la irá terminar a sessão imediatamente e terá de iniciar sessão novamente. Continuar?", - "revoke_confirm": "Revogar esta sessão? O próximo pedido desse browser receberá 401." + "revoke_confirm": "Revogar esta sessão? O próximo pedido desse browser receberá 401.", + "revoke_lag_notice": "Revogar uma sessão interrompe imediatamente o seu caminho de renovação, mas qualquer JWT já presente no navegador permanece válido por até {{secs}} segundos, até à próxima tentativa de renovação.", + "origin": { + "password": "Palavra-passe", + "opaque": "OPAQUE", + "magic_link": "Ligação mágica", + "oidc": "SSO", + "device": "Dispositivo", + "unknown": "Desconhecido" + } }, "smtp_fail": "Falha no envio.", "smtp_send": "Enviar", diff --git a/frontend/static/locales/ru.json b/frontend/static/locales/ru.json index 945b32f8..c9d665fb 100644 --- a/frontend/static/locales/ru.json +++ b/frontend/static/locales/ru.json @@ -848,6 +848,7 @@ "include_revoked": "Включая отозванные / истёкшие", "refresh": "Обновить", "col_user": "Пользователь", + "col_origin": "Источник", "col_created": "Создан", "col_expires": "Истекает", "col_ip": "IP", @@ -863,7 +864,16 @@ "revoke": "Отозвать", "empty": "Нет сеансов, соответствующих текущему фильтру.", "revoke_self_confirm": "⚠️ Это ВАШ текущий сеанс. При отзыве вы будете немедленно разлогинены и придётся войти снова. Продолжить?", - "revoke_confirm": "Отозвать этот сеанс? Следующий запрос из этого браузера получит 401." + "revoke_confirm": "Отозвать этот сеанс? Следующий запрос из этого браузера получит 401.", + "revoke_lag_notice": "Отзыв сессии немедленно прерывает путь её обновления, но любой JWT, уже находящийся в браузере, остаётся действительным до {{secs}} секунд, до следующей попытки обновления.", + "origin": { + "password": "Пароль", + "opaque": "OPAQUE", + "magic_link": "Волшебная ссылка", + "oidc": "SSO", + "device": "Устройство", + "unknown": "Неизвестно" + } }, "smtp_fail": "Сбой отправки.", "smtp_send": "Отправить", diff --git a/frontend/static/locales/zh-TW.json b/frontend/static/locales/zh-TW.json index 9fdd2699..48c38558 100644 --- a/frontend/static/locales/zh-TW.json +++ b/frontend/static/locales/zh-TW.json @@ -831,6 +831,7 @@ "include_revoked": "包含已撤銷 / 已過期", "refresh": "重新整理", "col_user": "使用者", + "col_origin": "來源", "col_created": "建立於", "col_expires": "過期於", "col_ip": "IP", @@ -846,7 +847,16 @@ "revoke": "撤銷", "empty": "沒有工作階段符合目前的篩選條件。", "revoke_self_confirm": "⚠️ 這是您目前的工作階段。撤銷後將立即登出,您必須重新登入。要繼續嗎?", - "revoke_confirm": "撤銷此工作階段?該瀏覽器的下一次請求將回傳 401。" + "revoke_confirm": "撤銷此工作階段?該瀏覽器的下一次請求將回傳 401。", + "revoke_lag_notice": "撤銷工作階段會立即中斷其重新整理路徑,但瀏覽器中已存在的任何 JWT 在下次重新整理嘗試之前最多可保持有效 {{secs}} 秒。", + "origin": { + "password": "密碼", + "opaque": "OPAQUE", + "magic_link": "魔法連結", + "oidc": "SSO", + "device": "裝置", + "unknown": "未知" + } }, "smtp_fail": "傳送失敗。", "smtp_send": "傳送", diff --git a/frontend/static/locales/zh.json b/frontend/static/locales/zh.json index c2834b2e..4ee69344 100644 --- a/frontend/static/locales/zh.json +++ b/frontend/static/locales/zh.json @@ -831,6 +831,7 @@ "include_revoked": "包含已撤销 / 已过期", "refresh": "刷新", "col_user": "用户", + "col_origin": "来源", "col_created": "创建于", "col_expires": "过期于", "col_ip": "IP", @@ -846,7 +847,16 @@ "revoke": "撤销", "empty": "没有会话与当前筛选条件匹配。", "revoke_self_confirm": "⚠️ 这是您当前的会话。撤销后您将立即被注销并需要重新登录。是否继续?", - "revoke_confirm": "撤销此会话吗?该浏览器的下一次请求将返回 401。" + "revoke_confirm": "撤销此会话吗?该浏览器的下一次请求将返回 401。", + "revoke_lag_notice": "撤销会话会立即中断其刷新路径,但浏览器中已存在的任何 JWT 在下次刷新尝试之前最多可保持有效 {{secs}} 秒。", + "origin": { + "password": "密码", + "opaque": "OPAQUE", + "magic_link": "魔法链接", + "oidc": "SSO", + "device": "设备", + "unknown": "未知" + } }, "smtp_fail": "发送失败。", "smtp_send": "发送", diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index adbb9de0..e49c2f10 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -1018,7 +1018,7 @@ impl AuthApplicationService { user_agent, crate::domain::entities::session::SessionOrigin::Password, ) - .await + .await } /// Emit a fresh session for a user who has ALREADY been diff --git a/src/bin/opaque-hurl-helper.rs b/src/bin/opaque-hurl-helper.rs index 697e70aa..4bca8578 100644 --- a/src/bin/opaque-hurl-helper.rs +++ b/src/bin/opaque-hurl-helper.rs @@ -360,6 +360,48 @@ async fn main() -> ExitCode { Err(e) => return fail(format!("/api/auth/me network: {e}")), } - eprintln!("opaque-hurl-helper: OK — register + login + /me round-trip for '{username}'"); + // SessionOrigin regression pin. The OPAQUE mint path funnels + // through `mint_session_for_authenticated_user(_, _, _, _, + // SessionOrigin::Opaque)`; a refactor that dropped that arg or + // wired the wrong variant would surface here as `unknown` (or + // any other origin) in the admin panel's row list. We can't + // check this from Hurl because /api/auth/login refuses migrated + // OPAQUE accounts (Phase 4 gate) — the OPAQUE-minted bearer is + // the ONLY credential this helper has access to at this point, + // so the assertion has to live in the same binary. + // + // No user_id filter needed: the test DB carries a single user + // (admin) at this stage, and `include_revoked=true` guarantees + // the OPAQUE row is in-frame even if a follow-up test has + // rotated it. Cheap substring check on the JSON body — we don't + // need to parse the array because "opaque" is a distinctive + // enough string that a false positive would require an + // origin-shaped `"opaque"` elsewhere in the wire payload, which + // the SessionSummaryDto shape rules out by construction. + match http + .get(format!( + "{base}/api/admin/sessions?include_revoked=true&limit=100" + )) + .header("Authorization", format!("Bearer {}", auth.access_token)) + .send() + .await + { + Ok(r) if r.status().is_success() => match r.text().await { + Ok(body) if body.contains("\"origin\":\"opaque\"") => {} + Ok(body) => { + return fail(format!( + "/api/admin/sessions: OPAQUE session not found in body — origin field missing or wrong variant. Body: {}", + &body[..body.len().min(512)] + )); + } + Err(e) => return fail(format!("/api/admin/sessions body read: {e}")), + }, + Ok(r) => { + return fail(format!("/api/admin/sessions: HTTP {}", r.status())); + } + Err(e) => return fail(format!("/api/admin/sessions network: {e}")), + } + + eprintln!("opaque-hurl-helper: OK — register + login + /me + admin sessions origin=opaque for '{username}'"); ExitCode::from(EXIT_OK) } diff --git a/src/domain/entities/session.rs b/src/domain/entities/session.rs index bf85674e..7e30653a 100644 --- a/src/domain/entities/session.rs +++ b/src/domain/entities/session.rs @@ -42,10 +42,14 @@ impl SessionOrigin { } } - /// Parse from the column string. Any unrecognised value maps to - /// `Unknown` — matches the CHECK constraint's failure mode - /// (impossible on well-behaved writes, defensive on load). - pub fn from_str(s: &str) -> Self { + /// Parse from the column / wire string. Any unrecognised value + /// maps to `Unknown` — matches the CHECK constraint's failure + /// mode (impossible on well-behaved writes, defensive on load). + /// Named `from_wire` (not `from_str`) to avoid shadowing the + /// standard `std::str::FromStr::from_str` trait method, which + /// would force us to pick a meaningless `Err` type when this + /// helper is intentionally infallible. + pub fn from_wire(s: &str) -> Self { match s { "password" => Self::Password, "opaque" => Self::Opaque, @@ -321,9 +325,9 @@ mod tests { SessionOrigin::Device, SessionOrigin::Unknown, ] { - assert_eq!(SessionOrigin::from_str(o.as_str()), o); + assert_eq!(SessionOrigin::from_wire(o.as_str()), o); } // Unknown catches typos / drift-off-column-values. - assert_eq!(SessionOrigin::from_str("bogus"), SessionOrigin::Unknown); + assert_eq!(SessionOrigin::from_wire("bogus"), SessionOrigin::Unknown); } } diff --git a/src/infrastructure/repositories/pg/session_pg_repository.rs b/src/infrastructure/repositories/pg/session_pg_repository.rs index bd461f66..a714babe 100644 --- a/src/infrastructure/repositories/pg/session_pg_repository.rs +++ b/src/infrastructure/repositories/pg/session_pg_repository.rs @@ -140,7 +140,7 @@ impl SessionRepository for SessionPgRepository { row.get("oidc_id_token"), row.get("oidc_sid"), row.get("dpop_jkt"), - crate::domain::entities::session::SessionOrigin::from_str(row.get("origin")), + crate::domain::entities::session::SessionOrigin::from_wire(row.get("origin")), )) } @@ -178,7 +178,7 @@ impl SessionRepository for SessionPgRepository { row.get("oidc_id_token"), row.get("oidc_sid"), row.get("dpop_jkt"), - crate::domain::entities::session::SessionOrigin::from_str(row.get("origin")), + crate::domain::entities::session::SessionOrigin::from_wire(row.get("origin")), )) } @@ -219,7 +219,7 @@ impl SessionRepository for SessionPgRepository { row.get("oidc_id_token"), row.get("oidc_sid"), row.get("dpop_jkt"), - crate::domain::entities::session::SessionOrigin::from_str(row.get("origin")), + crate::domain::entities::session::SessionOrigin::from_wire(row.get("origin")), ) }) .collect(); @@ -280,7 +280,7 @@ impl SessionRepository for SessionPgRepository { row.get("oidc_id_token"), row.get("oidc_sid"), row.get("dpop_jkt"), - crate::domain::entities::session::SessionOrigin::from_str(row.get("origin")), + crate::domain::entities::session::SessionOrigin::from_wire(row.get("origin")), ) }) .collect(); diff --git a/src/interfaces/api/handlers/admin_handler.rs b/src/interfaces/api/handlers/admin_handler.rs index 090cb362..6d252543 100644 --- a/src/interfaces/api/handlers/admin_handler.rs +++ b/src/interfaces/api/handlers/admin_handler.rs @@ -1263,10 +1263,18 @@ pub async fn list_sessions( .await .map_err(AppError::from)?; + // Also publish the current access-token TTL so the admin panel can + // render an honest "revoke takes effect within N seconds" warning + // above the table. Revoking a session flips the DB row (breaks the + // refresh path), but any in-flight JWT stays valid until its `exp` + // — which is `access_token_expiry_secs` from now. Showing this + // number keeps the UX honest instead of implying instant kill. + let access_token_expiry_secs = state.core.config.auth.access_token_expiry_secs; Ok(Json(serde_json::json!({ "sessions": sessions, "limit": limit, "offset": offset, + "access_token_expiry_secs": access_token_expiry_secs, }))) } diff --git a/tests/api/auth_magic_link_login.hurl b/tests/api/auth_magic_link_login.hurl index 36b596da..da3e7197 100644 --- a/tests/api/auth_magic_link_login.hurl +++ b/tests/api/auth_magic_link_login.hurl @@ -144,6 +144,41 @@ HTTP 200 [Asserts] jsonpath "$.email" == "{{email}}" jsonpath "$.username" == "{{username}}" +[Captures] +admin_user_id: jsonpath "$.id" + + +# ───────────────────────────────────────────────────────────── +# Step 7b — SessionOrigin stamping regression. Every login handler +# records HOW the session was minted; the admin panel +# surfaces that. This step proves TWO origins land +# correctly on the same account: +# * `password` — from Steps 1-2 legacy /api/auth/login +# * `magic_link` — from Step 6 magic-link redemption +# A missing/drifted stamp (e.g. a handler forgetting to +# pass the SessionOrigin arg after a refactor) would +# surface here as `unknown` instead of the expected value. +# +# `include_revoked=true` because Step 1 and Step 2 both +# create sessions and the second may have rotated the +# first out — we want ALL of admin's sessions in-frame. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/admin/sessions?user_id={{admin_user_id}}&include_revoked=true +Authorization: Bearer {{alice_token}} + +HTTP 200 +[Asserts] +# `body contains` rather than a jsonpath collection predicate because +# Hurl unwraps single-element `[*]` results to scalars — see the same +# pattern in tests/oidc/oidc.hurl Step 8b for the full reasoning. +body contains "\"origin\":\"password\"" +body contains "\"origin\":\"magic_link\"" +# `access_token_expiry_secs` also served for the SPA's revoke-lag +# notice. Belt-and-braces with tests/oidc/oidc.hurl Step 8b (same +# handler, both suites verify the field ships so a shape change +# would fail at least one of them). +jsonpath "$.access_token_expiry_secs" isInteger +jsonpath "$.access_token_expiry_secs" > 0 # ───────────────────────────────────────────────────────────── diff --git a/tests/oidc/oidc.hurl b/tests/oidc/oidc.hurl index a57d5b79..026523fb 100644 --- a/tests/oidc/oidc.hurl +++ b/tests/oidc/oidc.hurl @@ -267,6 +267,12 @@ HTTP 200 [Captures] refreshed_access_token: jsonpath "$.access_token" refreshed_refresh_token: jsonpath "$.refresh_token" +# Also capture the ROTATED csrf token so Step 8c (logout POST) can +# thread it into `X-CSRF-Token`. The refresh handler rotates all +# three cookies including csrf — using `initial_csrf_token` here +# would 403 at the CSRF middleware because it no longer matches +# the (freshly-rotated) `oxicloud_csrf` cookie on the browser. +refreshed_csrf_token: cookie "oxicloud_csrf" [Asserts] jsonpath "$.user.username" == "oidc_user" jsonpath "$.access_token" isString @@ -293,6 +299,79 @@ HTTP 200 jsonpath "$.username" == "oidc_user" +# ───────────────────────────────────────────────────────────── +# Step 8b — Admin sessions panel exposes `origin = "oidc"` and +# NEVER leaks the raw IdP `sid`. The oidc_user was JIT- +# provisioned admin by its `groups` claim (see Step 6), +# so /api/admin/sessions is reachable with the current +# cookies. Also asserts `access_token_expiry_secs` is +# served so the SPA can render the revoke-lag notice. +# +# Regressions this pins: +# * origin column drops back to "unknown" on refresh +# (would break the panel filter for OIDC-only view); +# * DTO reintroduces `oidc_sid` (the anti-leak fix from +# dto_never_leaks_oidc_sid in session_dto.rs); +# * handler stops publishing the TTL (would silently +# kill the revoke-lag notice on the admin page). +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/admin/sessions?limit=25 + +HTTP 200 +[Asserts] +# At least our own OIDC session is here. `body contains` rather than a +# jsonpath collection predicate because Hurl unwraps single-element +# `$.sessions[*].origin` results to a scalar, and the deprecated +# `includes` didn't handle that consistently either. Distinctive-enough +# substring — a false positive would need `"origin":"oidc"` to appear +# elsewhere in the SessionSummaryDto shape, which by construction it +# doesn't. +body contains "\"origin\":\"oidc\"" +# TTL surfaces for the SPA's revoke-lag notice. +jsonpath "$.access_token_expiry_secs" isInteger +jsonpath "$.access_token_expiry_secs" > 0 +# The raw IdP sid must never appear in the wire shape — not the key, +# not even a prefix (see dto_never_leaks_oidc_sid unit test). +body not contains "oidc_sid" +# id_token is a JWT — three base64-url parts joined by `.`. A leak +# would materialise as a long dotted token in the response body. +# The fake IdP's issuer URL is a durable substring of every id_token +# claim payload, so absence of that URL is a cheap "no id_token +# leaked" proof. +body not contains "{{oidc_issuer}}" + + +# ───────────────────────────────────────────────────────────── +# Step 8c — RP-initiated logout. Server MUST return +# `post_logout_url` on the /api/auth/logout response +# because the session's `oidc_id_token` is populated +# (both at OIDC-callback INSERT time AND — critically +# — after the Step 7 refresh which used to drop it). +# This is the regression that made "logout after OIDC +# login → refresh → logout" go through the local-only +# path, leaving the IdP session live. +# +# The post_logout_url is the IdP's `end_session_endpoint` +# carrying `id_token_hint` + `post_logout_redirect_uri`. +# We assert its shape rather than following it (Step 9 +# does a fresh OIDC login anyway). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/logout +X-CSRF-Token: {{refreshed_csrf_token}} +Content-Type: application/json +{} + +HTTP 200 +[Asserts] +jsonpath "$.post_logout_url" exists +jsonpath "$.post_logout_url" matches "id_token_hint=" +jsonpath "$.post_logout_url" matches "post_logout_redirect_uri=" +# Cookie-clearing is covered by other tests +# (`auth_session_lifecycle.hurl`); the point of THIS step is the +# `post_logout_url` shape — proving the id_token carried across the +# Step 7 refresh, which the bug we fixed used to drop. + + # ───────────────────────────────────────────────────────────── # Step 9 — Existing-user re-login. A second pass through the same # OIDC `sub` MUST resolve back to the SAME local user
{t('admin.sessions.col_user', 'User')}{t('admin.sessions.col_origin', 'Origin')} {t('admin.sessions.col_created', 'Created')} {t('admin.sessions.col_expires', 'Expires')} {t('admin.sessions.col_ip', 'IP')} + + {t(`admin.sessions.origin.${s.origin}`, s.origin)} + + {new Date(s.created_at).toLocaleString()} {new Date(s.expires_at).toLocaleString()} {s.ip_address ?? '—'}
+ {t('admin.sessions.empty', 'No sessions match the current filter.')}