diff --git a/lib/i18n/da.i18n.json b/lib/i18n/da.i18n.json index d9b844cb..3706336b 100644 --- a/lib/i18n/da.i18n.json +++ b/lib/i18n/da.i18n.json @@ -1040,6 +1040,12 @@ "openToActivate": "Åbn ${service} for at aktivere", "waitingForAuthorization": "Venter på godkendelse…", "codeCopied": "Kode kopieret" + }, + "oauthProxy": { + "title": "Log ind på ${service}", + "body": "Scan denne QR-kode med din telefon, eller åbn URL'en nedenfor på en enhed med browser.", + "openToSignIn": "Åbn ${service} for at logge ind", + "urlCopied": "URL kopieret" } } } diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index 1fbee2b8..26278ec1 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -1040,6 +1040,12 @@ "openToActivate": "${service} zum Aktivieren öffnen", "waitingForAuthorization": "Warte auf Autorisierung…", "codeCopied": "Code kopiert" + }, + "oauthProxy": { + "title": "Bei ${service} anmelden", + "body": "Scanne diesen QR-Code mit deinem Handy oder öffne die URL unten auf einem Gerät mit Browser.", + "openToSignIn": "${service} zum Anmelden öffnen", + "urlCopied": "URL kopiert" } } } diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index 0f12e7e6..48593450 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -1040,6 +1040,12 @@ "openToActivate": "Open ${service} to activate", "waitingForAuthorization": "Waiting for authorization…", "codeCopied": "Code copied" + }, + "oauthProxy": { + "title": "Sign in to ${service}", + "body": "Scan this QR code with your phone, or open the URL below on any device with a browser.", + "openToSignIn": "Open ${service} to sign in", + "urlCopied": "URL copied" } } } diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index 1bdb5f03..42f392c5 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -1040,6 +1040,12 @@ "openToActivate": "Abrir ${service} para activar", "waitingForAuthorization": "Esperando autorización…", "codeCopied": "Código copiado" + }, + "oauthProxy": { + "title": "Inicia sesión en ${service}", + "body": "Escanea este código QR con tu teléfono, o abre la URL de abajo en cualquier dispositivo con navegador.", + "openToSignIn": "Abrir ${service} para iniciar sesión", + "urlCopied": "URL copiada" } } } diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index a522ee45..e6bc6df9 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -1040,6 +1040,12 @@ "openToActivate": "Ouvrir ${service} pour activer", "waitingForAuthorization": "En attente d'autorisation…", "codeCopied": "Code copié" + }, + "oauthProxy": { + "title": "Se connecter à ${service}", + "body": "Scannez ce code QR avec votre téléphone, ou ouvrez l'URL ci-dessous sur un appareil équipé d'un navigateur.", + "openToSignIn": "Ouvrir ${service} pour se connecter", + "urlCopied": "URL copiée" } } } diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index 5ed18be5..b77137c3 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -1040,6 +1040,12 @@ "openToActivate": "Apri ${service} per attivare", "waitingForAuthorization": "In attesa di autorizzazione…", "codeCopied": "Codice copiato" + }, + "oauthProxy": { + "title": "Accedi a ${service}", + "body": "Scansiona questo codice QR con il telefono oppure apri l'URL sottostante su un dispositivo con browser.", + "openToSignIn": "Apri ${service} per accedere", + "urlCopied": "URL copiato" } } } diff --git a/lib/i18n/ja.i18n.json b/lib/i18n/ja.i18n.json index ae54e515..a8c2ee54 100644 --- a/lib/i18n/ja.i18n.json +++ b/lib/i18n/ja.i18n.json @@ -1040,6 +1040,12 @@ "openToActivate": "${service} を開いて有効化", "waitingForAuthorization": "認証を待っています…", "codeCopied": "コードをコピーしました" + }, + "oauthProxy": { + "title": "${service} にサインイン", + "body": "このQRコードをスマートフォンで読み取るか、下のURLをブラウザで開いてください。", + "openToSignIn": "${service} を開いてサインイン", + "urlCopied": "URLをコピーしました" } } } diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index 9319e075..e79d4105 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -1040,6 +1040,12 @@ "openToActivate": "활성화하려면 ${service} 열기", "waitingForAuthorization": "인증을 기다리는 중…", "codeCopied": "코드가 복사되었습니다" + }, + "oauthProxy": { + "title": "${service}에 로그인", + "body": "휴대전화로 이 QR 코드를 스캔하거나 아래 URL을 브라우저가 있는 기기에서 여세요.", + "openToSignIn": "로그인하려면 ${service} 열기", + "urlCopied": "URL이 복사되었습니다" } } } diff --git a/lib/i18n/nb.i18n.json b/lib/i18n/nb.i18n.json index 8a4c36f9..eacc98b8 100644 --- a/lib/i18n/nb.i18n.json +++ b/lib/i18n/nb.i18n.json @@ -1040,6 +1040,12 @@ "openToActivate": "Åpne ${service} for å aktivere", "waitingForAuthorization": "Venter på godkjenning…", "codeCopied": "Kode kopiert" + }, + "oauthProxy": { + "title": "Logg inn på ${service}", + "body": "Skann denne QR-koden med telefonen din, eller åpne URL-en under på en enhet med nettleser.", + "openToSignIn": "Åpne ${service} for å logge inn", + "urlCopied": "URL kopiert" } } } diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index a3a7ad30..d171ca0d 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -1040,6 +1040,12 @@ "openToActivate": "Open ${service} om te activeren", "waitingForAuthorization": "Wachten op autorisatie…", "codeCopied": "Code gekopieerd" + }, + "oauthProxy": { + "title": "Aanmelden bij ${service}", + "body": "Scan deze QR-code met je telefoon, of open onderstaande URL op een apparaat met browser.", + "openToSignIn": "${service} openen om aan te melden", + "urlCopied": "URL gekopieerd" } } } diff --git a/lib/i18n/pl.i18n.json b/lib/i18n/pl.i18n.json index 60692f8a..436f3073 100644 --- a/lib/i18n/pl.i18n.json +++ b/lib/i18n/pl.i18n.json @@ -1040,6 +1040,12 @@ "openToActivate": "Otwórz ${service}, aby aktywować", "waitingForAuthorization": "Oczekiwanie na autoryzację…", "codeCopied": "Kod skopiowany" + }, + "oauthProxy": { + "title": "Zaloguj się do ${service}", + "body": "Zeskanuj ten kod QR telefonem lub otwórz poniższy adres URL na dowolnym urządzeniu z przeglądarką.", + "openToSignIn": "Otwórz ${service}, aby się zalogować", + "urlCopied": "URL skopiowany" } } } diff --git a/lib/i18n/pt.i18n.json b/lib/i18n/pt.i18n.json index c8616c01..6f717ae0 100644 --- a/lib/i18n/pt.i18n.json +++ b/lib/i18n/pt.i18n.json @@ -1040,6 +1040,12 @@ "openToActivate": "Abrir ${service} para ativar", "waitingForAuthorization": "Aguardando autorização…", "codeCopied": "Código copiado" + }, + "oauthProxy": { + "title": "Entrar no ${service}", + "body": "Escaneie este código QR com o celular, ou abra o URL abaixo em qualquer dispositivo com navegador.", + "openToSignIn": "Abrir ${service} para entrar", + "urlCopied": "URL copiado" } } } diff --git a/lib/i18n/ru.i18n.json b/lib/i18n/ru.i18n.json index cc54615d..5b0df64b 100644 --- a/lib/i18n/ru.i18n.json +++ b/lib/i18n/ru.i18n.json @@ -1040,6 +1040,12 @@ "openToActivate": "Открыть ${service} для активации", "waitingForAuthorization": "Ожидание авторизации…", "codeCopied": "Код скопирован" + }, + "oauthProxy": { + "title": "Войти в ${service}", + "body": "Отсканируйте этот QR-код телефоном или откройте ссылку ниже на любом устройстве с браузером.", + "openToSignIn": "Открыть ${service} для входа", + "urlCopied": "URL скопирован" } } } diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 425a1669..13742d8e 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 15 -/// Strings: 14145 (943 per locale) +/// Strings: 14205 (947 per locale) /// -/// Built on 2026-04-24 at 06:22 UTC +/// Built on 2026-04-24 at 07:00 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/lib/i18n/strings_da.g.dart b/lib/i18n/strings_da.g.dart index f2fd6abd..4c659a4c 100644 --- a/lib/i18n/strings_da.g.dart +++ b/lib/i18n/strings_da.g.dart @@ -1298,6 +1298,7 @@ class _TranslationsTrackersDa implements TranslationsTrackersEn { @override String connectFailed({required Object service}) => 'Kunne ikke forbinde til ${service}. Prøv igen.'; @override late final _TranslationsTrackersServicesDa services = _TranslationsTrackersServicesDa._(_root); @override late final _TranslationsTrackersDeviceCodeDa deviceCode = _TranslationsTrackersDeviceCodeDa._(_root); + @override late final _TranslationsTrackersOauthProxyDa oauthProxy = _TranslationsTrackersOauthProxyDa._(_root); } // Path: hotkeys.actions @@ -1482,6 +1483,19 @@ class _TranslationsTrackersDeviceCodeDa implements TranslationsTrackersDeviceCod @override String get codeCopied => 'Kode kopieret'; } +// Path: trackers.oauthProxy +class _TranslationsTrackersOauthProxyDa implements TranslationsTrackersOauthProxyEn { + _TranslationsTrackersOauthProxyDa._(this._root); + + final TranslationsDa _root; // ignore: unused_field + + // Translations + @override String title({required Object service}) => 'Log ind på ${service}'; + @override String get body => 'Scan denne QR-kode med din telefon, eller åbn URL\'en nedenfor på en enhed med browser.'; + @override String openToSignIn({required Object service}) => 'Åbn ${service} for at logge ind'; + @override String get urlCopied => 'URL kopieret'; +} + /// The flat map containing all translations for locale . /// Only for edge cases! For simple maps, use the map function of this library. /// @@ -2435,6 +2449,10 @@ extension on TranslationsDa { 'trackers.deviceCode.openToActivate' => ({required Object service}) => 'Åbn ${service} for at aktivere', 'trackers.deviceCode.waitingForAuthorization' => 'Venter på godkendelse…', 'trackers.deviceCode.codeCopied' => 'Kode kopieret', + 'trackers.oauthProxy.title' => ({required Object service}) => 'Log ind på ${service}', + 'trackers.oauthProxy.body' => 'Scan denne QR-kode med din telefon, eller åbn URL\'en nedenfor på en enhed med browser.', + 'trackers.oauthProxy.openToSignIn' => ({required Object service}) => 'Åbn ${service} for at logge ind', + 'trackers.oauthProxy.urlCopied' => 'URL kopieret', _ => null, }; } diff --git a/lib/i18n/strings_de.g.dart b/lib/i18n/strings_de.g.dart index 5264270d..083c1167 100644 --- a/lib/i18n/strings_de.g.dart +++ b/lib/i18n/strings_de.g.dart @@ -1298,6 +1298,7 @@ class _TranslationsTrackersDe implements TranslationsTrackersEn { @override String connectFailed({required Object service}) => 'Verbindung zu ${service} fehlgeschlagen. Versuche es erneut.'; @override late final _TranslationsTrackersServicesDe services = _TranslationsTrackersServicesDe._(_root); @override late final _TranslationsTrackersDeviceCodeDe deviceCode = _TranslationsTrackersDeviceCodeDe._(_root); + @override late final _TranslationsTrackersOauthProxyDe oauthProxy = _TranslationsTrackersOauthProxyDe._(_root); } // Path: hotkeys.actions @@ -1482,6 +1483,19 @@ class _TranslationsTrackersDeviceCodeDe implements TranslationsTrackersDeviceCod @override String get codeCopied => 'Code kopiert'; } +// Path: trackers.oauthProxy +class _TranslationsTrackersOauthProxyDe implements TranslationsTrackersOauthProxyEn { + _TranslationsTrackersOauthProxyDe._(this._root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String title({required Object service}) => 'Bei ${service} anmelden'; + @override String get body => 'Scanne diesen QR-Code mit deinem Handy oder öffne die URL unten auf einem Gerät mit Browser.'; + @override String openToSignIn({required Object service}) => '${service} zum Anmelden öffnen'; + @override String get urlCopied => 'URL kopiert'; +} + /// The flat map containing all translations for locale . /// Only for edge cases! For simple maps, use the map function of this library. /// @@ -2435,6 +2449,10 @@ extension on TranslationsDe { 'trackers.deviceCode.openToActivate' => ({required Object service}) => '${service} zum Aktivieren öffnen', 'trackers.deviceCode.waitingForAuthorization' => 'Warte auf Autorisierung…', 'trackers.deviceCode.codeCopied' => 'Code kopiert', + 'trackers.oauthProxy.title' => ({required Object service}) => 'Bei ${service} anmelden', + 'trackers.oauthProxy.body' => 'Scanne diesen QR-Code mit deinem Handy oder öffne die URL unten auf einem Gerät mit Browser.', + 'trackers.oauthProxy.openToSignIn' => ({required Object service}) => '${service} zum Anmelden öffnen', + 'trackers.oauthProxy.urlCopied' => 'URL kopiert', _ => null, }; } diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index 72575340..c810bd99 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -2990,6 +2990,7 @@ class TranslationsTrackersEn { late final TranslationsTrackersServicesEn services = TranslationsTrackersServicesEn._(_root); late final TranslationsTrackersDeviceCodeEn deviceCode = TranslationsTrackersDeviceCodeEn._(_root); + late final TranslationsTrackersOauthProxyEn oauthProxy = TranslationsTrackersOauthProxyEn._(_root); } // Path: hotkeys.actions @@ -3376,6 +3377,27 @@ class TranslationsTrackersDeviceCodeEn { String get codeCopied => 'Code copied'; } +// Path: trackers.oauthProxy +class TranslationsTrackersOauthProxyEn { + TranslationsTrackersOauthProxyEn._(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'Sign in to ${service}' + String title({required Object service}) => 'Sign in to ${service}'; + + /// en: 'Scan this QR code with your phone, or open the URL below on any device with a browser.' + String get body => 'Scan this QR code with your phone, or open the URL below on any device with a browser.'; + + /// en: 'Open ${service} to sign in' + String openToSignIn({required Object service}) => 'Open ${service} to sign in'; + + /// en: 'URL copied' + String get urlCopied => 'URL copied'; +} + /// The flat map containing all translations for locale . /// Only for edge cases! For simple maps, use the map function of this library. /// @@ -4329,6 +4351,10 @@ extension on Translations { 'trackers.deviceCode.openToActivate' => ({required Object service}) => 'Open ${service} to activate', 'trackers.deviceCode.waitingForAuthorization' => 'Waiting for authorization…', 'trackers.deviceCode.codeCopied' => 'Code copied', + 'trackers.oauthProxy.title' => ({required Object service}) => 'Sign in to ${service}', + 'trackers.oauthProxy.body' => 'Scan this QR code with your phone, or open the URL below on any device with a browser.', + 'trackers.oauthProxy.openToSignIn' => ({required Object service}) => 'Open ${service} to sign in', + 'trackers.oauthProxy.urlCopied' => 'URL copied', _ => null, }; } diff --git a/lib/i18n/strings_es.g.dart b/lib/i18n/strings_es.g.dart index e2855f12..88863f77 100644 --- a/lib/i18n/strings_es.g.dart +++ b/lib/i18n/strings_es.g.dart @@ -1298,6 +1298,7 @@ class _TranslationsTrackersEs implements TranslationsTrackersEn { @override String connectFailed({required Object service}) => 'No se pudo conectar a ${service}. Inténtalo de nuevo.'; @override late final _TranslationsTrackersServicesEs services = _TranslationsTrackersServicesEs._(_root); @override late final _TranslationsTrackersDeviceCodeEs deviceCode = _TranslationsTrackersDeviceCodeEs._(_root); + @override late final _TranslationsTrackersOauthProxyEs oauthProxy = _TranslationsTrackersOauthProxyEs._(_root); } // Path: hotkeys.actions @@ -1482,6 +1483,19 @@ class _TranslationsTrackersDeviceCodeEs implements TranslationsTrackersDeviceCod @override String get codeCopied => 'Código copiado'; } +// Path: trackers.oauthProxy +class _TranslationsTrackersOauthProxyEs implements TranslationsTrackersOauthProxyEn { + _TranslationsTrackersOauthProxyEs._(this._root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String title({required Object service}) => 'Inicia sesión en ${service}'; + @override String get body => 'Escanea este código QR con tu teléfono, o abre la URL de abajo en cualquier dispositivo con navegador.'; + @override String openToSignIn({required Object service}) => 'Abrir ${service} para iniciar sesión'; + @override String get urlCopied => 'URL copiada'; +} + /// The flat map containing all translations for locale . /// Only for edge cases! For simple maps, use the map function of this library. /// @@ -2435,6 +2449,10 @@ extension on TranslationsEs { 'trackers.deviceCode.openToActivate' => ({required Object service}) => 'Abrir ${service} para activar', 'trackers.deviceCode.waitingForAuthorization' => 'Esperando autorización…', 'trackers.deviceCode.codeCopied' => 'Código copiado', + 'trackers.oauthProxy.title' => ({required Object service}) => 'Inicia sesión en ${service}', + 'trackers.oauthProxy.body' => 'Escanea este código QR con tu teléfono, o abre la URL de abajo en cualquier dispositivo con navegador.', + 'trackers.oauthProxy.openToSignIn' => ({required Object service}) => 'Abrir ${service} para iniciar sesión', + 'trackers.oauthProxy.urlCopied' => 'URL copiada', _ => null, }; } diff --git a/lib/i18n/strings_fr.g.dart b/lib/i18n/strings_fr.g.dart index a12597bd..85c067d3 100644 --- a/lib/i18n/strings_fr.g.dart +++ b/lib/i18n/strings_fr.g.dart @@ -1298,6 +1298,7 @@ class _TranslationsTrackersFr implements TranslationsTrackersEn { @override String connectFailed({required Object service}) => 'Échec de la connexion à ${service}. Réessayez.'; @override late final _TranslationsTrackersServicesFr services = _TranslationsTrackersServicesFr._(_root); @override late final _TranslationsTrackersDeviceCodeFr deviceCode = _TranslationsTrackersDeviceCodeFr._(_root); + @override late final _TranslationsTrackersOauthProxyFr oauthProxy = _TranslationsTrackersOauthProxyFr._(_root); } // Path: hotkeys.actions @@ -1482,6 +1483,19 @@ class _TranslationsTrackersDeviceCodeFr implements TranslationsTrackersDeviceCod @override String get codeCopied => 'Code copié'; } +// Path: trackers.oauthProxy +class _TranslationsTrackersOauthProxyFr implements TranslationsTrackersOauthProxyEn { + _TranslationsTrackersOauthProxyFr._(this._root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String title({required Object service}) => 'Se connecter à ${service}'; + @override String get body => 'Scannez ce code QR avec votre téléphone, ou ouvrez l\'URL ci-dessous sur un appareil équipé d\'un navigateur.'; + @override String openToSignIn({required Object service}) => 'Ouvrir ${service} pour se connecter'; + @override String get urlCopied => 'URL copiée'; +} + /// The flat map containing all translations for locale . /// Only for edge cases! For simple maps, use the map function of this library. /// @@ -2435,6 +2449,10 @@ extension on TranslationsFr { 'trackers.deviceCode.openToActivate' => ({required Object service}) => 'Ouvrir ${service} pour activer', 'trackers.deviceCode.waitingForAuthorization' => 'En attente d\'autorisation…', 'trackers.deviceCode.codeCopied' => 'Code copié', + 'trackers.oauthProxy.title' => ({required Object service}) => 'Se connecter à ${service}', + 'trackers.oauthProxy.body' => 'Scannez ce code QR avec votre téléphone, ou ouvrez l\'URL ci-dessous sur un appareil équipé d\'un navigateur.', + 'trackers.oauthProxy.openToSignIn' => ({required Object service}) => 'Ouvrir ${service} pour se connecter', + 'trackers.oauthProxy.urlCopied' => 'URL copiée', _ => null, }; } diff --git a/lib/i18n/strings_it.g.dart b/lib/i18n/strings_it.g.dart index ab1bf948..81bc7bcd 100644 --- a/lib/i18n/strings_it.g.dart +++ b/lib/i18n/strings_it.g.dart @@ -1298,6 +1298,7 @@ class _TranslationsTrackersIt implements TranslationsTrackersEn { @override String connectFailed({required Object service}) => 'Impossibile connettersi a ${service}. Riprova.'; @override late final _TranslationsTrackersServicesIt services = _TranslationsTrackersServicesIt._(_root); @override late final _TranslationsTrackersDeviceCodeIt deviceCode = _TranslationsTrackersDeviceCodeIt._(_root); + @override late final _TranslationsTrackersOauthProxyIt oauthProxy = _TranslationsTrackersOauthProxyIt._(_root); } // Path: hotkeys.actions @@ -1482,6 +1483,19 @@ class _TranslationsTrackersDeviceCodeIt implements TranslationsTrackersDeviceCod @override String get codeCopied => 'Codice copiato'; } +// Path: trackers.oauthProxy +class _TranslationsTrackersOauthProxyIt implements TranslationsTrackersOauthProxyEn { + _TranslationsTrackersOauthProxyIt._(this._root); + + final TranslationsIt _root; // ignore: unused_field + + // Translations + @override String title({required Object service}) => 'Accedi a ${service}'; + @override String get body => 'Scansiona questo codice QR con il telefono oppure apri l\'URL sottostante su un dispositivo con browser.'; + @override String openToSignIn({required Object service}) => 'Apri ${service} per accedere'; + @override String get urlCopied => 'URL copiato'; +} + /// The flat map containing all translations for locale . /// Only for edge cases! For simple maps, use the map function of this library. /// @@ -2435,6 +2449,10 @@ extension on TranslationsIt { 'trackers.deviceCode.openToActivate' => ({required Object service}) => 'Apri ${service} per attivare', 'trackers.deviceCode.waitingForAuthorization' => 'In attesa di autorizzazione…', 'trackers.deviceCode.codeCopied' => 'Codice copiato', + 'trackers.oauthProxy.title' => ({required Object service}) => 'Accedi a ${service}', + 'trackers.oauthProxy.body' => 'Scansiona questo codice QR con il telefono oppure apri l\'URL sottostante su un dispositivo con browser.', + 'trackers.oauthProxy.openToSignIn' => ({required Object service}) => 'Apri ${service} per accedere', + 'trackers.oauthProxy.urlCopied' => 'URL copiato', _ => null, }; } diff --git a/lib/i18n/strings_ja.g.dart b/lib/i18n/strings_ja.g.dart index 4a89e77c..00eec88d 100644 --- a/lib/i18n/strings_ja.g.dart +++ b/lib/i18n/strings_ja.g.dart @@ -1298,6 +1298,7 @@ class _TranslationsTrackersJa implements TranslationsTrackersEn { @override String connectFailed({required Object service}) => '${service} に接続できませんでした。もう一度お試しください。'; @override late final _TranslationsTrackersServicesJa services = _TranslationsTrackersServicesJa._(_root); @override late final _TranslationsTrackersDeviceCodeJa deviceCode = _TranslationsTrackersDeviceCodeJa._(_root); + @override late final _TranslationsTrackersOauthProxyJa oauthProxy = _TranslationsTrackersOauthProxyJa._(_root); } // Path: hotkeys.actions @@ -1482,6 +1483,19 @@ class _TranslationsTrackersDeviceCodeJa implements TranslationsTrackersDeviceCod @override String get codeCopied => 'コードをコピーしました'; } +// Path: trackers.oauthProxy +class _TranslationsTrackersOauthProxyJa implements TranslationsTrackersOauthProxyEn { + _TranslationsTrackersOauthProxyJa._(this._root); + + final TranslationsJa _root; // ignore: unused_field + + // Translations + @override String title({required Object service}) => '${service} にサインイン'; + @override String get body => 'このQRコードをスマートフォンで読み取るか、下のURLをブラウザで開いてください。'; + @override String openToSignIn({required Object service}) => '${service} を開いてサインイン'; + @override String get urlCopied => 'URLをコピーしました'; +} + /// The flat map containing all translations for locale . /// Only for edge cases! For simple maps, use the map function of this library. /// @@ -2435,6 +2449,10 @@ extension on TranslationsJa { 'trackers.deviceCode.openToActivate' => ({required Object service}) => '${service} を開いて有効化', 'trackers.deviceCode.waitingForAuthorization' => '認証を待っています…', 'trackers.deviceCode.codeCopied' => 'コードをコピーしました', + 'trackers.oauthProxy.title' => ({required Object service}) => '${service} にサインイン', + 'trackers.oauthProxy.body' => 'このQRコードをスマートフォンで読み取るか、下のURLをブラウザで開いてください。', + 'trackers.oauthProxy.openToSignIn' => ({required Object service}) => '${service} を開いてサインイン', + 'trackers.oauthProxy.urlCopied' => 'URLをコピーしました', _ => null, }; } diff --git a/lib/i18n/strings_ko.g.dart b/lib/i18n/strings_ko.g.dart index a9555c22..185ca27e 100644 --- a/lib/i18n/strings_ko.g.dart +++ b/lib/i18n/strings_ko.g.dart @@ -1298,6 +1298,7 @@ class _TranslationsTrackersKo implements TranslationsTrackersEn { @override String connectFailed({required Object service}) => '${service}에 연결할 수 없습니다. 다시 시도하세요.'; @override late final _TranslationsTrackersServicesKo services = _TranslationsTrackersServicesKo._(_root); @override late final _TranslationsTrackersDeviceCodeKo deviceCode = _TranslationsTrackersDeviceCodeKo._(_root); + @override late final _TranslationsTrackersOauthProxyKo oauthProxy = _TranslationsTrackersOauthProxyKo._(_root); } // Path: hotkeys.actions @@ -1482,6 +1483,19 @@ class _TranslationsTrackersDeviceCodeKo implements TranslationsTrackersDeviceCod @override String get codeCopied => '코드가 복사되었습니다'; } +// Path: trackers.oauthProxy +class _TranslationsTrackersOauthProxyKo implements TranslationsTrackersOauthProxyEn { + _TranslationsTrackersOauthProxyKo._(this._root); + + final TranslationsKo _root; // ignore: unused_field + + // Translations + @override String title({required Object service}) => '${service}에 로그인'; + @override String get body => '휴대전화로 이 QR 코드를 스캔하거나 아래 URL을 브라우저가 있는 기기에서 여세요.'; + @override String openToSignIn({required Object service}) => '로그인하려면 ${service} 열기'; + @override String get urlCopied => 'URL이 복사되었습니다'; +} + /// The flat map containing all translations for locale . /// Only for edge cases! For simple maps, use the map function of this library. /// @@ -2435,6 +2449,10 @@ extension on TranslationsKo { 'trackers.deviceCode.openToActivate' => ({required Object service}) => '활성화하려면 ${service} 열기', 'trackers.deviceCode.waitingForAuthorization' => '인증을 기다리는 중…', 'trackers.deviceCode.codeCopied' => '코드가 복사되었습니다', + 'trackers.oauthProxy.title' => ({required Object service}) => '${service}에 로그인', + 'trackers.oauthProxy.body' => '휴대전화로 이 QR 코드를 스캔하거나 아래 URL을 브라우저가 있는 기기에서 여세요.', + 'trackers.oauthProxy.openToSignIn' => ({required Object service}) => '로그인하려면 ${service} 열기', + 'trackers.oauthProxy.urlCopied' => 'URL이 복사되었습니다', _ => null, }; } diff --git a/lib/i18n/strings_nb.g.dart b/lib/i18n/strings_nb.g.dart index 12472ba4..8d27ebdd 100644 --- a/lib/i18n/strings_nb.g.dart +++ b/lib/i18n/strings_nb.g.dart @@ -1298,6 +1298,7 @@ class _TranslationsTrackersNb implements TranslationsTrackersEn { @override String connectFailed({required Object service}) => 'Kunne ikke koble til ${service}. Prøv igjen.'; @override late final _TranslationsTrackersServicesNb services = _TranslationsTrackersServicesNb._(_root); @override late final _TranslationsTrackersDeviceCodeNb deviceCode = _TranslationsTrackersDeviceCodeNb._(_root); + @override late final _TranslationsTrackersOauthProxyNb oauthProxy = _TranslationsTrackersOauthProxyNb._(_root); } // Path: hotkeys.actions @@ -1482,6 +1483,19 @@ class _TranslationsTrackersDeviceCodeNb implements TranslationsTrackersDeviceCod @override String get codeCopied => 'Kode kopiert'; } +// Path: trackers.oauthProxy +class _TranslationsTrackersOauthProxyNb implements TranslationsTrackersOauthProxyEn { + _TranslationsTrackersOauthProxyNb._(this._root); + + final TranslationsNb _root; // ignore: unused_field + + // Translations + @override String title({required Object service}) => 'Logg inn på ${service}'; + @override String get body => 'Skann denne QR-koden med telefonen din, eller åpne URL-en under på en enhet med nettleser.'; + @override String openToSignIn({required Object service}) => 'Åpne ${service} for å logge inn'; + @override String get urlCopied => 'URL kopiert'; +} + /// The flat map containing all translations for locale . /// Only for edge cases! For simple maps, use the map function of this library. /// @@ -2435,6 +2449,10 @@ extension on TranslationsNb { 'trackers.deviceCode.openToActivate' => ({required Object service}) => 'Åpne ${service} for å aktivere', 'trackers.deviceCode.waitingForAuthorization' => 'Venter på godkjenning…', 'trackers.deviceCode.codeCopied' => 'Kode kopiert', + 'trackers.oauthProxy.title' => ({required Object service}) => 'Logg inn på ${service}', + 'trackers.oauthProxy.body' => 'Skann denne QR-koden med telefonen din, eller åpne URL-en under på en enhet med nettleser.', + 'trackers.oauthProxy.openToSignIn' => ({required Object service}) => 'Åpne ${service} for å logge inn', + 'trackers.oauthProxy.urlCopied' => 'URL kopiert', _ => null, }; } diff --git a/lib/i18n/strings_nl.g.dart b/lib/i18n/strings_nl.g.dart index 7f89b499..f2ea401f 100644 --- a/lib/i18n/strings_nl.g.dart +++ b/lib/i18n/strings_nl.g.dart @@ -1298,6 +1298,7 @@ class _TranslationsTrackersNl implements TranslationsTrackersEn { @override String connectFailed({required Object service}) => 'Kan niet verbinden met ${service}. Probeer opnieuw.'; @override late final _TranslationsTrackersServicesNl services = _TranslationsTrackersServicesNl._(_root); @override late final _TranslationsTrackersDeviceCodeNl deviceCode = _TranslationsTrackersDeviceCodeNl._(_root); + @override late final _TranslationsTrackersOauthProxyNl oauthProxy = _TranslationsTrackersOauthProxyNl._(_root); } // Path: hotkeys.actions @@ -1482,6 +1483,19 @@ class _TranslationsTrackersDeviceCodeNl implements TranslationsTrackersDeviceCod @override String get codeCopied => 'Code gekopieerd'; } +// Path: trackers.oauthProxy +class _TranslationsTrackersOauthProxyNl implements TranslationsTrackersOauthProxyEn { + _TranslationsTrackersOauthProxyNl._(this._root); + + final TranslationsNl _root; // ignore: unused_field + + // Translations + @override String title({required Object service}) => 'Aanmelden bij ${service}'; + @override String get body => 'Scan deze QR-code met je telefoon, of open onderstaande URL op een apparaat met browser.'; + @override String openToSignIn({required Object service}) => '${service} openen om aan te melden'; + @override String get urlCopied => 'URL gekopieerd'; +} + /// The flat map containing all translations for locale . /// Only for edge cases! For simple maps, use the map function of this library. /// @@ -2435,6 +2449,10 @@ extension on TranslationsNl { 'trackers.deviceCode.openToActivate' => ({required Object service}) => 'Open ${service} om te activeren', 'trackers.deviceCode.waitingForAuthorization' => 'Wachten op autorisatie…', 'trackers.deviceCode.codeCopied' => 'Code gekopieerd', + 'trackers.oauthProxy.title' => ({required Object service}) => 'Aanmelden bij ${service}', + 'trackers.oauthProxy.body' => 'Scan deze QR-code met je telefoon, of open onderstaande URL op een apparaat met browser.', + 'trackers.oauthProxy.openToSignIn' => ({required Object service}) => '${service} openen om aan te melden', + 'trackers.oauthProxy.urlCopied' => 'URL gekopieerd', _ => null, }; } diff --git a/lib/i18n/strings_pl.g.dart b/lib/i18n/strings_pl.g.dart index 927f3928..bf1d8753 100644 --- a/lib/i18n/strings_pl.g.dart +++ b/lib/i18n/strings_pl.g.dart @@ -1298,6 +1298,7 @@ class _TranslationsTrackersPl implements TranslationsTrackersEn { @override String connectFailed({required Object service}) => 'Nie udało się połączyć z ${service}. Spróbuj ponownie.'; @override late final _TranslationsTrackersServicesPl services = _TranslationsTrackersServicesPl._(_root); @override late final _TranslationsTrackersDeviceCodePl deviceCode = _TranslationsTrackersDeviceCodePl._(_root); + @override late final _TranslationsTrackersOauthProxyPl oauthProxy = _TranslationsTrackersOauthProxyPl._(_root); } // Path: hotkeys.actions @@ -1482,6 +1483,19 @@ class _TranslationsTrackersDeviceCodePl implements TranslationsTrackersDeviceCod @override String get codeCopied => 'Kod skopiowany'; } +// Path: trackers.oauthProxy +class _TranslationsTrackersOauthProxyPl implements TranslationsTrackersOauthProxyEn { + _TranslationsTrackersOauthProxyPl._(this._root); + + final TranslationsPl _root; // ignore: unused_field + + // Translations + @override String title({required Object service}) => 'Zaloguj się do ${service}'; + @override String get body => 'Zeskanuj ten kod QR telefonem lub otwórz poniższy adres URL na dowolnym urządzeniu z przeglądarką.'; + @override String openToSignIn({required Object service}) => 'Otwórz ${service}, aby się zalogować'; + @override String get urlCopied => 'URL skopiowany'; +} + /// The flat map containing all translations for locale . /// Only for edge cases! For simple maps, use the map function of this library. /// @@ -2435,6 +2449,10 @@ extension on TranslationsPl { 'trackers.deviceCode.openToActivate' => ({required Object service}) => 'Otwórz ${service}, aby aktywować', 'trackers.deviceCode.waitingForAuthorization' => 'Oczekiwanie na autoryzację…', 'trackers.deviceCode.codeCopied' => 'Kod skopiowany', + 'trackers.oauthProxy.title' => ({required Object service}) => 'Zaloguj się do ${service}', + 'trackers.oauthProxy.body' => 'Zeskanuj ten kod QR telefonem lub otwórz poniższy adres URL na dowolnym urządzeniu z przeglądarką.', + 'trackers.oauthProxy.openToSignIn' => ({required Object service}) => 'Otwórz ${service}, aby się zalogować', + 'trackers.oauthProxy.urlCopied' => 'URL skopiowany', _ => null, }; } diff --git a/lib/i18n/strings_pt.g.dart b/lib/i18n/strings_pt.g.dart index 5be7156c..6b8d46a2 100644 --- a/lib/i18n/strings_pt.g.dart +++ b/lib/i18n/strings_pt.g.dart @@ -1298,6 +1298,7 @@ class _TranslationsTrackersPt implements TranslationsTrackersEn { @override String connectFailed({required Object service}) => 'Não foi possível conectar ao ${service}. Tente novamente.'; @override late final _TranslationsTrackersServicesPt services = _TranslationsTrackersServicesPt._(_root); @override late final _TranslationsTrackersDeviceCodePt deviceCode = _TranslationsTrackersDeviceCodePt._(_root); + @override late final _TranslationsTrackersOauthProxyPt oauthProxy = _TranslationsTrackersOauthProxyPt._(_root); } // Path: hotkeys.actions @@ -1482,6 +1483,19 @@ class _TranslationsTrackersDeviceCodePt implements TranslationsTrackersDeviceCod @override String get codeCopied => 'Código copiado'; } +// Path: trackers.oauthProxy +class _TranslationsTrackersOauthProxyPt implements TranslationsTrackersOauthProxyEn { + _TranslationsTrackersOauthProxyPt._(this._root); + + final TranslationsPt _root; // ignore: unused_field + + // Translations + @override String title({required Object service}) => 'Entrar no ${service}'; + @override String get body => 'Escaneie este código QR com o celular, ou abra o URL abaixo em qualquer dispositivo com navegador.'; + @override String openToSignIn({required Object service}) => 'Abrir ${service} para entrar'; + @override String get urlCopied => 'URL copiado'; +} + /// The flat map containing all translations for locale . /// Only for edge cases! For simple maps, use the map function of this library. /// @@ -2435,6 +2449,10 @@ extension on TranslationsPt { 'trackers.deviceCode.openToActivate' => ({required Object service}) => 'Abrir ${service} para ativar', 'trackers.deviceCode.waitingForAuthorization' => 'Aguardando autorização…', 'trackers.deviceCode.codeCopied' => 'Código copiado', + 'trackers.oauthProxy.title' => ({required Object service}) => 'Entrar no ${service}', + 'trackers.oauthProxy.body' => 'Escaneie este código QR com o celular, ou abra o URL abaixo em qualquer dispositivo com navegador.', + 'trackers.oauthProxy.openToSignIn' => ({required Object service}) => 'Abrir ${service} para entrar', + 'trackers.oauthProxy.urlCopied' => 'URL copiado', _ => null, }; } diff --git a/lib/i18n/strings_ru.g.dart b/lib/i18n/strings_ru.g.dart index b727ae81..ed8241df 100644 --- a/lib/i18n/strings_ru.g.dart +++ b/lib/i18n/strings_ru.g.dart @@ -1298,6 +1298,7 @@ class _TranslationsTrackersRu implements TranslationsTrackersEn { @override String connectFailed({required Object service}) => 'Не удалось подключиться к ${service}. Попробуйте ещё раз.'; @override late final _TranslationsTrackersServicesRu services = _TranslationsTrackersServicesRu._(_root); @override late final _TranslationsTrackersDeviceCodeRu deviceCode = _TranslationsTrackersDeviceCodeRu._(_root); + @override late final _TranslationsTrackersOauthProxyRu oauthProxy = _TranslationsTrackersOauthProxyRu._(_root); } // Path: hotkeys.actions @@ -1482,6 +1483,19 @@ class _TranslationsTrackersDeviceCodeRu implements TranslationsTrackersDeviceCod @override String get codeCopied => 'Код скопирован'; } +// Path: trackers.oauthProxy +class _TranslationsTrackersOauthProxyRu implements TranslationsTrackersOauthProxyEn { + _TranslationsTrackersOauthProxyRu._(this._root); + + final TranslationsRu _root; // ignore: unused_field + + // Translations + @override String title({required Object service}) => 'Войти в ${service}'; + @override String get body => 'Отсканируйте этот QR-код телефоном или откройте ссылку ниже на любом устройстве с браузером.'; + @override String openToSignIn({required Object service}) => 'Открыть ${service} для входа'; + @override String get urlCopied => 'URL скопирован'; +} + /// The flat map containing all translations for locale . /// Only for edge cases! For simple maps, use the map function of this library. /// @@ -2435,6 +2449,10 @@ extension on TranslationsRu { 'trackers.deviceCode.openToActivate' => ({required Object service}) => 'Открыть ${service} для активации', 'trackers.deviceCode.waitingForAuthorization' => 'Ожидание авторизации…', 'trackers.deviceCode.codeCopied' => 'Код скопирован', + 'trackers.oauthProxy.title' => ({required Object service}) => 'Войти в ${service}', + 'trackers.oauthProxy.body' => 'Отсканируйте этот QR-код телефоном или откройте ссылку ниже на любом устройстве с браузером.', + 'trackers.oauthProxy.openToSignIn' => ({required Object service}) => 'Открыть ${service} для входа', + 'trackers.oauthProxy.urlCopied' => 'URL скопирован', _ => null, }; } diff --git a/lib/i18n/strings_sv.g.dart b/lib/i18n/strings_sv.g.dart index e115bd50..772f2567 100644 --- a/lib/i18n/strings_sv.g.dart +++ b/lib/i18n/strings_sv.g.dart @@ -1298,6 +1298,7 @@ class _TranslationsTrackersSv implements TranslationsTrackersEn { @override String connectFailed({required Object service}) => 'Kunde inte ansluta till ${service}. Försök igen.'; @override late final _TranslationsTrackersServicesSv services = _TranslationsTrackersServicesSv._(_root); @override late final _TranslationsTrackersDeviceCodeSv deviceCode = _TranslationsTrackersDeviceCodeSv._(_root); + @override late final _TranslationsTrackersOauthProxySv oauthProxy = _TranslationsTrackersOauthProxySv._(_root); } // Path: hotkeys.actions @@ -1482,6 +1483,19 @@ class _TranslationsTrackersDeviceCodeSv implements TranslationsTrackersDeviceCod @override String get codeCopied => 'Kod kopierad'; } +// Path: trackers.oauthProxy +class _TranslationsTrackersOauthProxySv implements TranslationsTrackersOauthProxyEn { + _TranslationsTrackersOauthProxySv._(this._root); + + final TranslationsSv _root; // ignore: unused_field + + // Translations + @override String title({required Object service}) => 'Logga in på ${service}'; + @override String get body => 'Skanna den här QR-koden med din telefon, eller öppna URL:en nedan på en enhet med webbläsare.'; + @override String openToSignIn({required Object service}) => 'Öppna ${service} för att logga in'; + @override String get urlCopied => 'URL kopierad'; +} + /// The flat map containing all translations for locale . /// Only for edge cases! For simple maps, use the map function of this library. /// @@ -2435,6 +2449,10 @@ extension on TranslationsSv { 'trackers.deviceCode.openToActivate' => ({required Object service}) => 'Öppna ${service} för att aktivera', 'trackers.deviceCode.waitingForAuthorization' => 'Väntar på auktorisering…', 'trackers.deviceCode.codeCopied' => 'Kod kopierad', + 'trackers.oauthProxy.title' => ({required Object service}) => 'Logga in på ${service}', + 'trackers.oauthProxy.body' => 'Skanna den här QR-koden med din telefon, eller öppna URL:en nedan på en enhet med webbläsare.', + 'trackers.oauthProxy.openToSignIn' => ({required Object service}) => 'Öppna ${service} för att logga in', + 'trackers.oauthProxy.urlCopied' => 'URL kopierad', _ => null, }; } diff --git a/lib/i18n/strings_zh.g.dart b/lib/i18n/strings_zh.g.dart index de7c1380..8255ed4d 100644 --- a/lib/i18n/strings_zh.g.dart +++ b/lib/i18n/strings_zh.g.dart @@ -1298,6 +1298,7 @@ class _TranslationsTrackersZh implements TranslationsTrackersEn { @override String connectFailed({required Object service}) => '无法连接到 ${service}。请重试。'; @override late final _TranslationsTrackersServicesZh services = _TranslationsTrackersServicesZh._(_root); @override late final _TranslationsTrackersDeviceCodeZh deviceCode = _TranslationsTrackersDeviceCodeZh._(_root); + @override late final _TranslationsTrackersOauthProxyZh oauthProxy = _TranslationsTrackersOauthProxyZh._(_root); } // Path: hotkeys.actions @@ -1482,6 +1483,19 @@ class _TranslationsTrackersDeviceCodeZh implements TranslationsTrackersDeviceCod @override String get codeCopied => '代码已复制'; } +// Path: trackers.oauthProxy +class _TranslationsTrackersOauthProxyZh implements TranslationsTrackersOauthProxyEn { + _TranslationsTrackersOauthProxyZh._(this._root); + + final TranslationsZh _root; // ignore: unused_field + + // Translations + @override String title({required Object service}) => '登录 ${service}'; + @override String get body => '用手机扫描此二维码,或在任何带浏览器的设备上打开下方 URL。'; + @override String openToSignIn({required Object service}) => '打开 ${service} 以登录'; + @override String get urlCopied => 'URL 已复制'; +} + /// The flat map containing all translations for locale . /// Only for edge cases! For simple maps, use the map function of this library. /// @@ -2435,6 +2449,10 @@ extension on TranslationsZh { 'trackers.deviceCode.openToActivate' => ({required Object service}) => '打开 ${service} 以激活', 'trackers.deviceCode.waitingForAuthorization' => '等待授权…', 'trackers.deviceCode.codeCopied' => '代码已复制', + 'trackers.oauthProxy.title' => ({required Object service}) => '登录 ${service}', + 'trackers.oauthProxy.body' => '用手机扫描此二维码,或在任何带浏览器的设备上打开下方 URL。', + 'trackers.oauthProxy.openToSignIn' => ({required Object service}) => '打开 ${service} 以登录', + 'trackers.oauthProxy.urlCopied' => 'URL 已复制', _ => null, }; } diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index 405c414e..410e8519 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -1040,6 +1040,12 @@ "openToActivate": "Öppna ${service} för att aktivera", "waitingForAuthorization": "Väntar på auktorisering…", "codeCopied": "Kod kopierad" + }, + "oauthProxy": { + "title": "Logga in på ${service}", + "body": "Skanna den här QR-koden med din telefon, eller öppna URL:en nedan på en enhet med webbläsare.", + "openToSignIn": "Öppna ${service} för att logga in", + "urlCopied": "URL kopierad" } } } diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index acfe76a5..ea236f93 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -1040,6 +1040,12 @@ "openToActivate": "打开 ${service} 以激活", "waitingForAuthorization": "等待授权…", "codeCopied": "代码已复制" + }, + "oauthProxy": { + "title": "登录 ${service}", + "body": "用手机扫描此二维码,或在任何带浏览器的设备上打开下方 URL。", + "openToSignIn": "打开 ${service} 以登录", + "urlCopied": "URL 已复制" } } } diff --git a/lib/providers/trackers_provider.dart b/lib/providers/trackers_provider.dart index b602dd03..8ebc6f4b 100644 --- a/lib/providers/trackers_provider.dart +++ b/lib/providers/trackers_provider.dart @@ -13,6 +13,7 @@ import '../services/trackers/mal/mal_auth_service.dart'; import '../services/trackers/mal/mal_client.dart'; import '../services/trackers/mal/mal_session.dart'; import '../services/trackers/mal/mal_tracker.dart'; +import '../services/trackers/oauth_proxy_client.dart'; import '../services/trackers/simkl/simkl_account_store.dart'; import '../services/trackers/simkl/simkl_auth_service.dart'; import '../services/trackers/simkl/simkl_client.dart'; @@ -31,6 +32,7 @@ enum TrackerService { mal, anilist, simkl } /// sessions from their stores and pushes them to their trackers. class TrackersProvider extends ChangeNotifier { final MalAuthService _malAuth = MalAuthService(); + final AnilistAuthService _anilistAuth = AnilistAuthService(); final SimklAuthService _simklAuth = SimklAuthService(); MalSession? _mal; @@ -55,9 +57,9 @@ class TrackersProvider extends ChangeNotifier { bool isConnecting(TrackerService service) => _connecting == service; - /// Cancel an in-flight device-code poll. Currently only Simkl is - /// cancellable — MAL/AniList are OS-browser driven, the user dismisses the - /// browser to abort. + /// Cancel an in-flight connect. Supported for all three services — Simkl's + /// device-code poll and MAL/AniList's OAuth-proxy long-poll both honor the + /// flag on their next tick. void cancelConnect() { _cancelRequested = true; } @@ -82,10 +84,13 @@ class TrackersProvider extends ChangeNotifier { // ───── Connect / disconnect ───── - Future connectMal() => _runConnect( + Future connectMal({required void Function(OAuthProxyStart) onCodeReady}) => _runConnect( service: TrackerService.mal, alreadyConnected: isMalConnected, - authorize: _malAuth.authorize, + authorize: () => _malAuth.authorize( + onCodeReady: onCodeReady, + shouldCancel: () => _cancelRequested, + ), enrich: _enrichMal, store: malAccountStore, assign: (s) { @@ -99,10 +104,13 @@ class TrackersProvider extends ChangeNotifier { _rebindMal(); }); - Future connectAnilist() => _runConnect( + Future connectAnilist({required void Function(OAuthProxyStart) onCodeReady}) => _runConnect( service: TrackerService.anilist, alreadyConnected: isAnilistConnected, - authorize: AnilistAuthService().authorize, + authorize: () => _anilistAuth.authorize( + onCodeReady: onCodeReady, + shouldCancel: () => _cancelRequested, + ), enrich: _enrichAnilist, store: anilistAccountStore, assign: (s) { @@ -271,6 +279,7 @@ class TrackersProvider extends ChangeNotifier { @override void dispose() { _malAuth.dispose(); + _anilistAuth.dispose(); _simklAuth.dispose(); super.dispose(); } diff --git a/lib/screens/settings/tracker_settings_screen.dart b/lib/screens/settings/tracker_settings_screen.dart index 136d0ca0..d090b0d1 100644 --- a/lib/screens/settings/tracker_settings_screen.dart +++ b/lib/screens/settings/tracker_settings_screen.dart @@ -7,9 +7,11 @@ import 'package:url_launcher/url_launcher.dart'; import '../../focus/input_mode_tracker.dart'; import '../../i18n/strings.g.dart'; +import '../../models/trackers/device_code.dart'; import '../../providers/trackers_provider.dart'; import '../../services/trackers/anilist/anilist_tracker.dart'; import '../../services/trackers/mal/mal_tracker.dart'; +import '../../services/trackers/oauth_proxy_client.dart'; import '../../services/trackers/simkl/simkl_tracker.dart'; import '../../services/settings_service.dart'; import '../../utils/app_logger.dart'; @@ -18,73 +20,97 @@ import '../../utils/snackbar_helper.dart'; import '../../widgets/app_icon.dart'; import '../../widgets/device_code_dialog.dart'; import '../../widgets/focused_scroll_scaffold.dart'; +import '../../widgets/oauth_proxy_dialog.dart'; import '../../widgets/settings_section.dart'; -/// Start MAL's loopback-OAuth flow from anywhere. The user's browser opens to -/// complete the flow; no in-app dialog is needed. Shows a snackbar on failure. -Future startMalConnection(BuildContext context) async { +/// Shared dialog-driven connect flow used by all three trackers. Handles the +/// guard, auto-launch-on-pointer, dialog lifecycle, and failure snackbar — +/// service-specific pieces are delegated via [connect], [buildDialog], and +/// [urlFor]. +Future _startConnectionWithDialog( + BuildContext context, { + required TrackerService service, + required bool alreadyConnected, + required String serviceName, + required Future Function(void Function(T)) connect, + required Widget Function(T payload, VoidCallback onCancel) buildDialog, + required String Function(T payload) urlFor, +}) async { final account = context.read(); - if (account.isConnecting(TrackerService.mal) || account.isMalConnected) return; - final ok = await account.connectMal(); - if (!context.mounted) return; - if (!ok && !account.isMalConnected) { - showAppSnackBar(context, t.trackers.connectFailed(service: t.trackers.services.mal)); - } -} - -/// Start AniList's loopback-OAuth flow from anywhere. -Future startAnilistConnection(BuildContext context) async { - final account = context.read(); - if (account.isConnecting(TrackerService.anilist) || account.isAnilistConnected) return; - final ok = await account.connectAnilist(); - if (!context.mounted) return; - if (!ok && !account.isAnilistConnected) { - showAppSnackBar(context, t.trackers.connectFailed(service: t.trackers.services.anilist)); - } -} - -/// Start Simkl's device-code flow. Mirrors `startTraktConnection` — shows the -/// PIN dialog, polls, auto-launches the browser on pointer-driven platforms. -Future startSimklConnection(BuildContext context) async { - final account = context.read(); - if (account.isConnecting(TrackerService.simkl) || account.isSimklConnected) return; + if (account.isConnecting(service) || alreadyConnected) return; final autoLaunchBrowser = !InputModeTracker.isKeyboardMode(context); var dialogOpen = false; - final ok = await account.connectSimkl( - onCodeReady: (code) { - if (!context.mounted) return; - dialogOpen = true; - showDialog( - context: context, - barrierDismissible: false, - builder: (_) => DeviceCodeDialog( - code: code, - serviceName: t.trackers.services.simkl, - onCancel: account.cancelConnect, - ), - ).whenComplete(() => dialogOpen = false); - if (autoLaunchBrowser) { - unawaited( - launchUrl(Uri.parse(code.verificationUrl), mode: LaunchMode.externalApplication).catchError((Object e) { - appLogger.d('Simkl: failed to auto-launch browser', error: e); - return false; - }), - ); - } - }, - ); + final ok = await connect((payload) { + if (!context.mounted) return; + dialogOpen = true; + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => buildDialog(payload, account.cancelConnect), + ).whenComplete(() => dialogOpen = false); + if (autoLaunchBrowser) { + unawaited( + launchUrl(Uri.parse(urlFor(payload)), mode: LaunchMode.externalApplication).catchError((Object e) { + appLogger.d('${service.name}: failed to auto-launch browser', error: e); + return false; + }), + ); + } + }); if (!context.mounted) return; if (dialogOpen) { Navigator.of(context, rootNavigator: true).pop(); } - if (!ok && !account.isSimklConnected) { - showAppSnackBar(context, t.trackers.connectFailed(service: t.trackers.services.simkl)); + if (!ok) { + showAppSnackBar(context, t.trackers.connectFailed(service: serviceName)); } } +Future startMalConnection(BuildContext context) { + final account = context.read(); + final name = t.trackers.services.mal; + return _startConnectionWithDialog( + context, + service: TrackerService.mal, + alreadyConnected: account.isMalConnected, + serviceName: name, + connect: (cb) => account.connectMal(onCodeReady: cb), + buildDialog: (p, cancel) => OAuthProxyDialog(start: p, serviceName: name, onCancel: cancel), + urlFor: (p) => p.url, + ); +} + +Future startAnilistConnection(BuildContext context) { + final account = context.read(); + final name = t.trackers.services.anilist; + return _startConnectionWithDialog( + context, + service: TrackerService.anilist, + alreadyConnected: account.isAnilistConnected, + serviceName: name, + connect: (cb) => account.connectAnilist(onCodeReady: cb), + buildDialog: (p, cancel) => OAuthProxyDialog(start: p, serviceName: name, onCancel: cancel), + urlFor: (p) => p.url, + ); +} + +Future startSimklConnection(BuildContext context) { + final account = context.read(); + final name = t.trackers.services.simkl; + return _startConnectionWithDialog( + context, + service: TrackerService.simkl, + alreadyConnected: account.isSimklConnected, + serviceName: name, + connect: (cb) => account.connectSimkl(onCodeReady: cb), + buildDialog: (p, cancel) => DeviceCodeDialog(code: p, serviceName: name, onCancel: cancel), + urlFor: (p) => p.verificationUrlComplete ?? p.verificationUrl, + ); +} + /// Per-service wiring for [TrackerSettingsScreen]. Keeps tracker-specific /// method names out of the shared screen body. class TrackerConfig { diff --git a/lib/services/trackers/anilist/anilist_auth_service.dart b/lib/services/trackers/anilist/anilist_auth_service.dart index 6515e068..b08e60d4 100644 --- a/lib/services/trackers/anilist/anilist_auth_service.dart +++ b/lib/services/trackers/anilist/anilist_auth_service.dart @@ -1,46 +1,29 @@ import 'dart:async'; -import '../loopback_auth_server.dart'; -import 'anilist_constants.dart'; +import '../oauth_proxy_client.dart'; import 'anilist_session.dart'; -/// AniList OAuth 2.0 implicit grant via RFC 8252 loopback redirect. +/// AniList authentication via the Plezy relay's OAuth proxy. /// -/// AniList returns the access token in the URL fragment, which browsers -/// don't send to servers. [LoopbackAuthServer] serves a tiny HTML page that -/// rewrites `location.hash` into a query string and reloads — the second -/// request is then captured normally. +/// We use AniList's authorization-code grant (not implicit), exchanged +/// server-side so the device never sees the fragment. The proxy handles both +/// state + client_secret; the device just gets the bearer token. class AnilistAuthService { - static const String _callbackPath = '/anilist-oauth'; + final OAuthProxyClient _proxy; - /// Drive the full flow. Returns `null` if the user closes the browser - /// before completing. - Future authorize() async { - // AniList's implicit grant rejects the authorize request when - // `redirect_uri` is present — MAL-Sync omits it too. The redirect URL - // registered for the client at anilist.co is used automatically. - final authorizeUri = Uri.parse(AnilistConstants.oauthAuthorizeUrl).replace( - queryParameters: {'client_id': AnilistConstants.clientId, 'response_type': 'token'}, - ); + AnilistAuthService({OAuthProxyClient? proxy}) : _proxy = proxy ?? OAuthProxyClient(); - final callback = await LoopbackAuthServer.launchAndWait(authorizeUri, path: _callbackPath); - if (callback == null) return null; + void dispose() => _proxy.dispose(); - final params = callback.queryParameters; - final token = params['access_token']; - if (token == null) { - throw AnilistAuthFlowException('AniList redirect missing access_token: $callback'); - } - - final expiresIn = int.tryParse(params['expires_in'] ?? '') ?? (365 * 24 * 60 * 60); - final createdAt = DateTime.now().millisecondsSinceEpoch ~/ 1000; - return AnilistSession(accessToken: token, expiresAt: createdAt + expiresIn, createdAt: createdAt); + /// Drive the full flow. Returns null on user cancel. + Future authorize({ + required void Function(OAuthProxyStart) onCodeReady, + bool Function()? shouldCancel, + }) async { + final start = await _proxy.start('anilist'); + onCodeReady(start); + final result = await _proxy.poll(start.session, shouldCancel: shouldCancel); + if (result == null) return null; + return AnilistSession.fromProxyResult(result); } } - -class AnilistAuthFlowException implements Exception { - final String message; - const AnilistAuthFlowException(this.message); - @override - String toString() => 'AnilistAuthFlowException: $message'; -} diff --git a/lib/services/trackers/anilist/anilist_constants.dart b/lib/services/trackers/anilist/anilist_constants.dart index 829f16a1..5a64885d 100644 --- a/lib/services/trackers/anilist/anilist_constants.dart +++ b/lib/services/trackers/anilist/anilist_constants.dart @@ -1,16 +1,12 @@ -/// Bundled AniList API credentials and endpoints. +/// Bundled AniList API endpoint. /// -/// Register at https://anilist.co/settings/developer — redirect URL must be -/// `http://127.0.0.1:53682/anilist-oauth` (RFC 8252 loopback). AniList uses -/// OAuth 2.0 Implicit Grant; access tokens are valid for 1 year and have no -/// refresh — the user must re-auth on expiry. +/// Auth is driven entirely by the Plezy relay's OAuth proxy — the device +/// never needs the AniList authorize URL, client ID, or client secret. +/// Tokens are valid for 1 year and have no refresh; users re-auth on expiry. class AnilistConstants { AnilistConstants._(); - static const String clientId = '39867'; - static const String apiBase = 'https://graphql.anilist.co'; - static const String oauthAuthorizeUrl = 'https://anilist.co/api/v2/oauth/authorize'; static Map headers({String? accessToken}) => { 'Content-Type': 'application/json', diff --git a/lib/services/trackers/anilist/anilist_session.dart b/lib/services/trackers/anilist/anilist_session.dart index e17995e8..0d26bf05 100644 --- a/lib/services/trackers/anilist/anilist_session.dart +++ b/lib/services/trackers/anilist/anilist_session.dart @@ -1,5 +1,7 @@ import 'dart:convert'; +import '../oauth_proxy_client.dart'; + /// Immutable AniList OAuth session. /// /// Implicit grant — no refresh token. Tokens are valid for 1 year; on expiry @@ -42,6 +44,19 @@ class AnilistSession { createdAt: (json['created_at'] as num).toInt(), ); + /// Build a session from the OAuth-proxy result. AniList tokens last 1 year + /// and have no refresh; when the proxy doesn't echo an explicit expiry we + /// default to the documented year. + factory AnilistSession.fromProxyResult(OAuthProxyResult r) { + final createdAt = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final expiresIn = r.expiresIn ?? 365 * 24 * 60 * 60; + return AnilistSession( + accessToken: r.accessToken, + expiresAt: createdAt + expiresIn, + createdAt: createdAt, + ); + } + String encode() => json.encode(toJson()); static AnilistSession decode(String raw) => AnilistSession.fromJson(json.decode(raw) as Map); } diff --git a/lib/services/trackers/loopback_auth_server.dart b/lib/services/trackers/loopback_auth_server.dart deleted file mode 100644 index 1a3e08a3..00000000 --- a/lib/services/trackers/loopback_auth_server.dart +++ /dev/null @@ -1,144 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:url_launcher/url_launcher.dart'; - -import '../../utils/app_logger.dart'; - -/// One-shot HTTP server on `127.0.0.1:[port]` that captures an OAuth redirect -/// (RFC 8252 Loopback Interface Redirection). -/// -/// Replaces the custom `plezy://` URL scheme used by `flutter_web_auth_2` — -/// no platform manifest registration needed. -/// -/// **iOS caveat**: when the app opens an external browser, iOS may suspend -/// the app after ~30 seconds, which silently kills this server. Users who -/// linger in 2FA / password managers may see "localhost refused to connect" -/// on the redirect. If this turns out to be common, fall back to the -/// custom-scheme approach on mobile only. -class LoopbackAuthServer { - /// Fixed port — must be registered as the redirect URI with each OAuth - /// provider. AniList requires exact URI match, so we can't pick dynamically. - static const int port = 53682; - static const String host = '127.0.0.1'; - - /// Listen for one request at `http://$host:$port$path` and return the - /// captured URI. - /// - /// Handles both query-param redirects (RFC 6749 authorization_code grant) - /// and fragment redirects (implicit grant): fragments stay client-side, so - /// we serve a tiny HTML page that rewrites `location.hash` into query - /// params and reloads — the second request is then captured normally. - static Future listenOnce({ - required String path, - Duration timeout = const Duration(minutes: 5), - }) async { - final HttpServer server; - try { - server = await HttpServer.bind(host, port); - } on SocketException catch (e) { - throw LoopbackBindException('Could not bind $host:$port: ${e.message}'); - } - - final completer = Completer(); - - late StreamSubscription sub; - sub = server.listen((req) async { - if (req.uri.path != path) { - req.response.statusCode = HttpStatus.notFound; - await req.response.close(); - return; - } - // Flush the response BEFORE completing the completer — the caller's - // `finally` block forcibly tears down the socket, which would - // otherwise truncate the response body and leave the browser showing - // a broken page despite auth succeeding. - await _respondHtml(req.response, _pageHtml); - if (req.uri.queryParameters.isNotEmpty && !completer.isCompleted) { - completer.complete(req.uri); - } - }); - - try { - return await completer.future.timeout(timeout); - } finally { - await sub.cancel(); - await server.close(force: true); - } - } - - /// Build the redirect URI callers should register with the OAuth provider. - static String redirectUri(String path) => 'http://$host:$port$path'; - - /// Open [authorizeUri] in the external browser and wait for the OAuth - /// provider's redirect to hit the loopback server. Returns `null` if the - /// redirect doesn't arrive within [timeout] (usually because the user - /// closed the browser). - /// - /// Starts the listener BEFORE launching the browser so a fast redirect - /// can't race the bind. - static Future launchAndWait( - Uri authorizeUri, { - required String path, - Duration timeout = const Duration(minutes: 5), - }) async { - final callbackFuture = listenOnce(path: path, timeout: timeout); - await launchUrl(authorizeUri, mode: LaunchMode.externalApplication); - try { - return await callbackFuture; - } on TimeoutException { - return null; - } - } - - static Future _respondHtml(HttpResponse res, String html) async { - try { - res - ..statusCode = HttpStatus.ok - ..headers.contentType = ContentType.html - ..write(html); - await res.close(); - } catch (e) { - appLogger.d('LoopbackAuthServer: response write failed', error: e); - } - } - - /// One page for all three flows. Code-grant and polling flows (MAL, Simkl) - /// render it as a plain success page. Implicit-grant flows (AniList) put - /// the access token in the URL fragment — the inline script in `` - /// rewrites that into a query string and redirects before the body paints, - /// so users never see a success page flash before the real capture. - static const String _pageHtml = '' - '' - '' - 'Signed in' - '' - '' - '' - '' - '' - '' - '' - '

Signed in to Plezy

' - '

You can close this tab and return to the app.

' - ''; -} - -class LoopbackBindException implements Exception { - final String message; - const LoopbackBindException(this.message); - @override - String toString() => 'LoopbackBindException: $message'; -} diff --git a/lib/services/trackers/mal/mal_auth_service.dart b/lib/services/trackers/mal/mal_auth_service.dart index 04223ae4..7c0ec800 100644 --- a/lib/services/trackers/mal/mal_auth_service.dart +++ b/lib/services/trackers/mal/mal_auth_service.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:math'; import 'package:http/http.dart' as http; @@ -8,71 +7,39 @@ import '../../../utils/app_logger.dart'; import '../../../utils/platform_http_client_stub.dart' if (dart.library.io) '../../../utils/platform_http_client_io.dart' as platform; -import '../loopback_auth_server.dart'; +import '../oauth_proxy_client.dart'; import 'mal_constants.dart'; import 'mal_session.dart'; -/// MyAnimeList OAuth 2.0 PKCE flow via RFC 8252 loopback redirect. +/// MyAnimeList authentication. /// -/// **Quirk**: MAL requires `code_challenge_method=plain` — it rejects `S256` -/// despite RFC 7636. +/// New sessions come from the Plezy relay's OAuth proxy (PKCE is server-side). +/// Refreshes are direct public-client calls against MAL's token endpoint — +/// no proxy needed because refresh requires no redirect. class MalAuthService { - static const String _callbackPath = '/mal-oauth'; - + final OAuthProxyClient _proxy; final http.Client _http; - MalAuthService({http.Client? httpClient}) : _http = httpClient ?? platform.createPlatformClient(); + MalAuthService({OAuthProxyClient? proxy, http.Client? httpClient}) + : _proxy = proxy ?? OAuthProxyClient(), + _http = httpClient ?? platform.createPlatformClient(); - void dispose() => _http.close(); + void dispose() { + _proxy.dispose(); + _http.close(); + } - /// Drive the full flow: build the authorize URL, start a loopback server - /// for the redirect, launch the browser, exchange the code for tokens. - /// Returns `null` if the user closes the browser before completing. - Future authorize() async { - final verifier = _randomVerifier(); - final state = _randomVerifier(length: 16); - final redirectUri = LoopbackAuthServer.redirectUri(_callbackPath); - - final authorizeUri = Uri.parse(MalConstants.authorizeUrl).replace( - queryParameters: { - 'response_type': 'code', - 'client_id': MalConstants.clientId, - 'code_challenge': verifier, // plain method → challenge == verifier - 'code_challenge_method': 'plain', - 'redirect_uri': redirectUri, - 'state': state, - }, - ); - - final callback = await LoopbackAuthServer.launchAndWait(authorizeUri, path: _callbackPath); - if (callback == null) return null; - - final code = callback.queryParameters['code']; - final returnedState = callback.queryParameters['state']; - if (code == null) { - throw MalAuthFlowException('MAL redirect missing code: $callback'); - } - if (returnedState != state) { - throw const MalAuthFlowException('MAL state mismatch (possible CSRF)'); - } - - final res = await _http - .post( - Uri.parse(MalConstants.tokenUrl), - body: { - 'client_id': MalConstants.clientId, - 'code': code, - 'code_verifier': verifier, - 'grant_type': 'authorization_code', - 'redirect_uri': redirectUri, - }, - ) - .timeout(const Duration(seconds: 20)); - - if (res.statusCode != 200) { - throw MalAuthFlowException('Token exchange failed: HTTP ${res.statusCode}: ${res.body}'); - } - return MalSession.fromTokenResponse(json.decode(res.body) as Map); + /// Drive the full flow. Invokes [onCodeReady] with the QR URL once the + /// session is created, then long-polls for tokens. Returns null on cancel. + Future authorize({ + required void Function(OAuthProxyStart) onCodeReady, + bool Function()? shouldCancel, + }) async { + final start = await _proxy.start('mal'); + onCodeReady(start); + final result = await _proxy.poll(start.session, shouldCancel: shouldCancel); + if (result == null) return null; + return MalSession.fromProxyResult(result); } Future refresh(MalSession current) async { @@ -94,13 +61,6 @@ class MalAuthService { final fresh = MalSession.fromTokenResponse(json.decode(res.body) as Map); return fresh.copyWith(username: current.username); } - - /// MAL requires 43–128 chars from the unreserved URL-safe set. - String _randomVerifier({int length = 64}) { - const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'; - final rand = Random.secure(); - return List.generate(length, (_) => alphabet[rand.nextInt(alphabet.length)]).join(); - } } class MalAuthFlowException implements Exception { diff --git a/lib/services/trackers/mal/mal_constants.dart b/lib/services/trackers/mal/mal_constants.dart index 19d5df61..f1f7c516 100644 --- a/lib/services/trackers/mal/mal_constants.dart +++ b/lib/services/trackers/mal/mal_constants.dart @@ -1,21 +1,15 @@ -/// Bundled MyAnimeList API credentials and endpoints. +/// Bundled MyAnimeList API endpoints and public client ID. /// -/// Register at https://myanimelist.net/apiconfig — "App type: Other", redirect -/// URI `http://127.0.0.1:53682/mal-oauth` (RFC 8252 loopback). PKCE-only -/// (no client secret). -/// -/// **MAL quirk**: `code_challenge_method` must be `plain` — MAL rejects `S256` -/// despite RFC 7636. See [MalAuthService.authorize]. +/// The authorize flow lives in the Plezy relay's OAuth proxy; see +/// `lib/services/trackers/oauth_proxy_client.dart`. Only the refresh path +/// (public-client, no redirect) calls MAL directly from the device. class MalConstants { MalConstants._(); static const String clientId = '463b1c92992505e4bdfcef6aab3aedbe'; static const String apiBase = 'https://api.myanimelist.net/v2'; - static const String oauthBase = 'https://myanimelist.net/v1/oauth2'; - - static const String authorizeUrl = '$oauthBase/authorize'; - static const String tokenUrl = '$oauthBase/token'; + static const String tokenUrl = 'https://myanimelist.net/v1/oauth2/token'; static Map headers({String? accessToken}) => { 'Accept': 'application/json', diff --git a/lib/services/trackers/mal/mal_session.dart b/lib/services/trackers/mal/mal_session.dart index d32f98b0..f7ff7c14 100644 --- a/lib/services/trackers/mal/mal_session.dart +++ b/lib/services/trackers/mal/mal_session.dart @@ -1,5 +1,7 @@ import 'dart:convert'; +import '../oauth_proxy_client.dart'; + /// Immutable MyAnimeList OAuth session. /// /// Access tokens expire in ~31 days. Refresh token rotates with each refresh @@ -60,6 +62,19 @@ class MalSession { ); } + /// Build a session from an OAuth-proxy result. MAL's refresh_token is + /// required for the 31-day refresh loop. + factory MalSession.fromProxyResult(OAuthProxyResult r) { + final createdAt = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final expiresIn = r.expiresIn ?? 31 * 24 * 60 * 60; + return MalSession( + accessToken: r.accessToken, + refreshToken: r.refreshToken ?? '', + expiresAt: createdAt + expiresIn, + createdAt: createdAt, + ); + } + String encode() => json.encode(toJson()); static MalSession decode(String raw) => MalSession.fromJson(json.decode(raw) as Map); } diff --git a/lib/services/trackers/oauth_proxy_client.dart b/lib/services/trackers/oauth_proxy_client.dart new file mode 100644 index 00000000..9bd52d64 --- /dev/null +++ b/lib/services/trackers/oauth_proxy_client.dart @@ -0,0 +1,121 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import '../../utils/app_logger.dart'; +import '../../utils/platform_http_client_stub.dart' + if (dart.library.io) '../../utils/platform_http_client_io.dart' + as platform; +import '../../watch_together/services/watch_together_peer_service.dart'; + +/// Client for the Plezy relay's `/auth/*` OAuth proxy. +/// +/// The proxy drives the full authorization-code flow server-side: device calls +/// [start] to get a QR URL, user scans on a phone to complete auth, device +/// long-polls [poll] until tokens arrive. No local HTTP listener or custom URL +/// scheme is required — works identically on TVs without a browser. +class OAuthProxyClient { + /// Public base URL of the Plezy relay; colocated with Watch Together. + static String get baseUrl => WatchTogetherPeerService.defaultBaseUrl; + + final http.Client _http; + + OAuthProxyClient({http.Client? httpClient}) : _http = httpClient ?? platform.createPlatformClient(); + + void dispose() => _http.close(); + + /// POST /auth/start — register a new session. Returns a handle including the + /// URL to display as a QR code for the phone scan. + Future start(String service) async { + final res = await _http + .post( + Uri.parse('$baseUrl/auth/start'), + headers: {'Content-Type': 'application/json'}, + body: json.encode({'service': service}), + ) + .timeout(const Duration(seconds: 15)); + if (res.statusCode != 200) { + throw OAuthProxyException('start failed: HTTP ${res.statusCode}: ${res.body}'); + } + final body = json.decode(res.body) as Map; + return OAuthProxyStart( + session: body['session'] as String, + url: body['url'] as String, + expiresIn: (body['expiresIn'] as num).toInt(), + ); + } + + /// Long-poll /auth/result?session=X until a completion event arrives. + /// + /// Returns null if [shouldCancel] flips true before a result arrives. Throws + /// [OAuthProxyException] on unrecoverable errors (session gone, upstream + /// failure). The server holds each request for up to 50 s; 204 responses are + /// retried transparently. + Future poll(String session, {bool Function()? shouldCancel}) async { + final uri = Uri.parse('$baseUrl/auth/result').replace(queryParameters: {'session': session}); + while (true) { + if (shouldCancel?.call() ?? false) return null; + + final http.Response res; + try { + res = await _http.get(uri).timeout(const Duration(seconds: 65)); + } on TimeoutException { + continue; + } catch (e) { + appLogger.d('oauth proxy: poll transient error', error: e); + await Future.delayed(const Duration(seconds: 2)); + continue; + } + + if (res.statusCode == 204) continue; // server-side timeout, retry + if (res.statusCode == 410) { + throw const OAuthProxyException('Session expired or already used'); + } + if (res.statusCode != 200) { + throw OAuthProxyException('poll failed: HTTP ${res.statusCode}: ${res.body}'); + } + final body = json.decode(res.body) as Map; + if (body['error'] != null) { + final err = body['error'] as String; + if (err == 'access_denied') return null; // user cancelled in browser + throw OAuthProxyException('Upstream auth failed: $err'); + } + return OAuthProxyResult( + accessToken: body['accessToken'] as String, + refreshToken: body['refreshToken'] as String?, + expiresIn: (body['expiresIn'] as num?)?.toInt(), + ); + } + } +} + +class OAuthProxyStart { + /// Opaque session token. Include in subsequent polls. + final String session; + + /// URL to render as a QR code and open in a browser. The phone scans it, + /// triggering the upstream OAuth flow. + final String url; + + /// Session TTL in seconds. After this, polls will 410 and the user must + /// restart. + final int expiresIn; + + const OAuthProxyStart({required this.session, required this.url, required this.expiresIn}); +} + +class OAuthProxyResult { + final String accessToken; + final String? refreshToken; + final int? expiresIn; + + const OAuthProxyResult({required this.accessToken, this.refreshToken, this.expiresIn}); +} + +class OAuthProxyException implements Exception { + final String message; + const OAuthProxyException(this.message); + @override + String toString() => 'OAuthProxyException: $message'; +} diff --git a/lib/services/trackers/simkl/simkl_auth_service.dart b/lib/services/trackers/simkl/simkl_auth_service.dart index ca7fa7fe..da7c1253 100644 --- a/lib/services/trackers/simkl/simkl_auth_service.dart +++ b/lib/services/trackers/simkl/simkl_auth_service.dart @@ -1,4 +1,3 @@ -import 'dart:async'; import 'dart:convert'; import 'package:http/http.dart' as http; @@ -9,21 +8,16 @@ import '../../../utils/platform_http_client_stub.dart' if (dart.library.io) '../../../utils/platform_http_client_io.dart' as platform; import '../device_code_poller.dart' as poller; -import '../loopback_auth_server.dart'; +import '../oauth_proxy_client.dart'; import 'simkl_constants.dart'; /// Simkl OAuth PIN (device-code) flow. /// -/// `GET /oauth/pin?client_id=...&redirect=http://127.0.0.1:53682/simkl-oauth` -/// returns a PIN the user enters at https://simkl.com/pin. After entry Simkl -/// redirects the browser to our loopback callback (where we show a friendly -/// "close this tab" page). The app polls `/oauth/pin/?client_id=...` -/// until `result == "OK"`. +/// `GET /oauth/pin?client_id=...&redirect=` returns a PIN the +/// user enters at https://simkl.com/pin. After entry Simkl redirects the +/// browser to the relay's static "signed in" page. The app polls +/// `/oauth/pin/?client_id=...` until `result == "OK"`. class SimklAuthService { - /// Redirect path Simkl bounces the browser to after PIN entry. Must match - /// the URL registered at simkl.com/settings/developer for this client. - static const String _callbackPath = '/simkl-oauth'; - final http.Client _http; SimklAuthService({http.Client? httpClient}) : _http = httpClient ?? platform.createPlatformClient(); @@ -34,7 +28,7 @@ class SimklAuthService { final uri = Uri.parse(SimklConstants.pinUrl).replace( queryParameters: { 'client_id': SimklConstants.clientId, - 'redirect': LoopbackAuthServer.redirectUri(_callbackPath), + 'redirect': '${OAuthProxyClient.baseUrl}/auth/done', }, ); final res = await _http.get(uri, headers: SimklConstants.headers()).timeout(const Duration(seconds: 15)); @@ -42,25 +36,13 @@ class SimklAuthService { throw SimklAuthFlowException('PIN request failed: HTTP ${res.statusCode}: ${res.body}'); } final body = json.decode(res.body) as Map; - final expiresIn = (body['expires_in'] as num?)?.toInt() ?? 900; - - // Serve the "close this tab" success page when Simkl redirects the - // browser back after PIN entry. Fire-and-forget — the PIN poll captures - // the token; this listener is purely cosmetic. - unawaited( - LoopbackAuthServer.listenOnce( - path: _callbackPath, - timeout: Duration(seconds: expiresIn), - ).catchError((Object _) => Uri()), - ); - return DeviceCode( deviceCode: body['device_code'] as String, userCode: body['user_code'] as String, verificationUrl: body['verification_url'] as String? ?? SimklConstants.verificationUrl, // Simkl doesn't expose a prefilled URL; the user manually enters the code. verificationUrlComplete: null, - expiresIn: expiresIn, + expiresIn: (body['expires_in'] as num?)?.toInt() ?? 900, interval: (body['interval'] as num?)?.toInt() ?? 5, ); } diff --git a/lib/widgets/oauth_proxy_dialog.dart b/lib/widgets/oauth_proxy_dialog.dart new file mode 100644 index 00000000..cfeb9bcf --- /dev/null +++ b/lib/widgets/oauth_proxy_dialog.dart @@ -0,0 +1,113 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../i18n/strings.g.dart'; +import '../services/trackers/oauth_proxy_client.dart'; +import '../utils/snackbar_helper.dart'; +import 'dialog_action_button.dart'; + +/// Sign-in dialog for OAuth-proxy flows (MAL, AniList). +/// +/// Shows a QR code plus a "open in browser" button — works uniformly on +/// phones (user taps the button), desktops (same), and TVs without a browser +/// (user scans the QR with a phone). +class OAuthProxyDialog extends StatelessWidget { + final OAuthProxyStart start; + final String serviceName; + final VoidCallback onCancel; + + const OAuthProxyDialog({ + super.key, + required this.start, + required this.serviceName, + required this.onCancel, + }); + + Future _open() async { + await launchUrl(Uri.parse(start.url), mode: LaunchMode.externalApplication); + } + + Future _copyUrl(BuildContext context) async { + await Clipboard.setData(ClipboardData(text: start.url)); + if (!context.mounted) return; + showAppSnackBar(context, t.trackers.oauthProxy.urlCopied); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return AlertDialog( + title: Text(t.trackers.oauthProxy.title(service: serviceName)), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(t.trackers.oauthProxy.body, style: theme.textTheme.bodyMedium), + const SizedBox(height: 16), + // QrImageView doesn't support intrinsic sizing; wrap in SizedBox so + // AlertDialog's IntrinsicWidth walk sees a concrete width. + Center( + child: SizedBox.square( + dimension: 220, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: QrImageView( + data: start.url, + size: 220, + version: QrVersions.auto, + backgroundColor: Colors.white, + ), + ), + ), + ), + const SizedBox(height: 16), + InkWell( + onTap: () => _copyUrl(context), + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Text( + start.url, + style: theme.textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + color: theme.colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + ), + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + icon: const Icon(Icons.open_in_new), + label: Text(t.trackers.oauthProxy.openToSignIn(service: serviceName)), + onPressed: _open, + ), + ), + const SizedBox(height: 16), + Row( + children: [ + const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)), + const SizedBox(width: 12), + Expanded( + child: Text(t.trackers.deviceCode.waitingForAuthorization, style: theme.textTheme.bodySmall), + ), + ], + ), + ], + ), + actions: [ + DialogActionButton( + onPressed: () { + onCancel(); + Navigator.of(context, rootNavigator: true).pop(); + }, + label: t.common.cancel, + ), + ], + ); + } +} diff --git a/server/Dockerfile b/server/Dockerfile index 4602d39b..3ce7380a 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -2,10 +2,11 @@ FROM golang:1.22-alpine AS build WORKDIR /src COPY go.mod go.sum ./ RUN go mod download -COPY main.go . +COPY *.go . RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /relay . FROM scratch +COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ COPY --from=build /relay /relay VOLUME /data EXPOSE 8080 diff --git a/server/deploy.sh b/server/deploy.sh new file mode 100755 index 00000000..4e184e59 --- /dev/null +++ b/server/deploy.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REMOTE="root@212.132.75.249" +SSH_KEY="$HOME/.ssh/id_edde" +REMOTE_DIR="/opt/plezy-relay" +SSH="ssh -i $SSH_KEY $REMOTE" + +cd "$SCRIPT_DIR" + +echo "Syncing files to $REMOTE:$REMOTE_DIR..." +rsync -avz --exclude='plezy-relay' --exclude='plezy-server-linux-amd64' \ + -e "ssh -i $SSH_KEY" \ + . "$REMOTE:$REMOTE_DIR/" + +echo "Deploying..." +$SSH "cd $REMOTE_DIR && docker compose up -d --build" + +echo "Done." diff --git a/server/docker-compose.yml b/server/docker-compose.yml index 2e012ff3..51eb7aec 100644 --- a/server/docker-compose.yml +++ b/server/docker-compose.yml @@ -8,6 +8,11 @@ services: - logs:/data expose: - "8080" + environment: + OAUTH_BASE_URL: https://ice.plezy.app + MAL_CLIENT_ID: ${MAL_CLIENT_ID:-} + ANILIST_CLIENT_ID: ${ANILIST_CLIENT_ID:-} + ANILIST_CLIENT_SECRET: ${ANILIST_CLIENT_SECRET:-} logging: driver: "json-file" options: diff --git a/server/main.go b/server/main.go index 47a192a6..fdde307e 100644 --- a/server/main.go +++ b/server/main.go @@ -97,6 +97,14 @@ func (rl *rateLimiter) allow() bool { return true } +// stale reports whether a limiter hasn't been touched in over 10 minutes — +// safe to GC from a per-IP map. +func (rl *rateLimiter) stale(now time.Time) bool { + rl.mu.Lock() + defer rl.mu.Unlock() + return now.Sub(rl.lastTime) > 10*time.Minute +} + // --- Connection tracker (per-IP limits) --- type connTracker struct { @@ -547,11 +555,16 @@ type Server struct { logs *logStore conns *connTracker snap *snapshotter + oauth *oauthProxy // nil when OAUTH_BASE_URL is unset mu sync.RWMutex } func newServer(logDir, stateFile string) *Server { s := &Server{rooms: make(map[string]*Room), logs: newLogStore(logDir), conns: newConnTracker()} + if p, ok := oauthConfigFromEnv(); ok { + s.oauth = p + log.Printf("oauth: proxy enabled (base=%s, services=%d)", p.baseURL, len(p.services)) + } s.snap = newSnapshotter(stateFile, s.buildSnapshot) if err := s.loadSnapshot(stateFile); err != nil { log.Printf("snapshot: load error: %v", err) @@ -669,6 +682,9 @@ func (s *Server) runCleanupStep(now time.Time) { } s.logs.cleanup() s.conns.cleanup() + if s.oauth != nil { + s.oauth.cleanup() + } s.conns.mu.Lock() log.Printf("stats: conns=%d ips=%d rooms=%d", @@ -996,6 +1012,7 @@ func main() { }) mux.HandleFunc("/logs", srv.handlePostLogs) mux.HandleFunc("/logs/", srv.handleGetLogs) + registerOAuthRoutes(mux, srv.oauth) httpSrv := &http.Server{Addr: *addr, Handler: mux} diff --git a/server/oauth.go b/server/oauth.go new file mode 100644 index 00000000..970b84cd --- /dev/null +++ b/server/oauth.go @@ -0,0 +1,492 @@ +package main + +// OAuth proxy: relays MAL + AniList authorization-code flows for devices that +// can't listen on localhost (TVs) or lack a browser (headless set-top boxes +// pair via a phone QR scan). Sessions live in memory for 10 minutes; AniList's +// client secret lives only in env vars. Access tokens transit the server +// briefly during code→token exchange and are never logged or persisted. + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "html" + "io" + "log" + "net/http" + "net/url" + "os" + "strings" + "sync" + "time" +) + +const ( + oauthSessionTTL = 10 * time.Minute + oauthResultWait = 50 * time.Second + oauthMaxSessions = 5000 + oauthStartBurst = 3 + oauthStartRateSustained = 1 + oauthSessionIDBytes = 18 // 144 bits → 24 base64url chars + oauthPKCEVerifierLen = 64 + oauthUpstreamTimeout = 15 * time.Second +) + +// oauthServiceConfig describes a single upstream OAuth provider. Populated from +// env vars in oauthConfigFromEnv. A service with an empty ClientID is disabled. +type oauthServiceConfig struct { + ClientID string + ClientSecret string // empty ⇒ provider doesn't issue/require one (MAL w/ PKCE) + AuthorizeURL string + TokenURL string + Scopes string + UsePKCE bool + PKCEMethod string // "plain" or "S256" +} + +type oauthTokenResult struct { + AccessToken string `json:"accessToken,omitempty"` + RefreshToken string `json:"refreshToken,omitempty"` + ExpiresIn int `json:"expiresIn,omitempty"` + Error string `json:"error,omitempty"` +} + +// oauthSession is created by /auth/start and lives until it's consumed by a +// successful /auth/result (which deletes the map entry) or GC'd after +// oauthSessionTTL. The `done` channel is closed exactly once (by complete) and +// unblocks waiters. +type oauthSession struct { + id string + service string + codeVerifier string // MAL PKCE; empty for AniList. Cleared after token exchange. + createdAt time.Time + done chan struct{} + + mu sync.Mutex + result *oauthTokenResult +} + +func (s *oauthSession) complete(r oauthTokenResult) { + s.mu.Lock() + defer s.mu.Unlock() + if s.result != nil { + return + } + s.result = &r + s.codeVerifier = "" // Secret, not needed after exchange. + close(s.done) +} + +// wait blocks until the session is completed or ctx is cancelled. +func (s *oauthSession) wait(ctx context.Context) (*oauthTokenResult, error) { + select { + case <-s.done: + s.mu.Lock() + defer s.mu.Unlock() + return s.result, nil + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +type oauthProxy struct { + baseURL string // e.g. https://ice.plezy.app + services map[string]oauthServiceConfig + client *http.Client + + mu sync.Mutex + sessions map[string]*oauthSession + + ipMu sync.Mutex + ipRate map[string]*rateLimiter +} + +func newOAuthProxy(baseURL string, services map[string]oauthServiceConfig) *oauthProxy { + return &oauthProxy{ + baseURL: strings.TrimRight(baseURL, "/"), + services: services, + client: &http.Client{Timeout: oauthUpstreamTimeout}, + sessions: make(map[string]*oauthSession), + ipRate: make(map[string]*rateLimiter), + } +} + +// oauthConfigFromEnv reads the public base URL and per-service creds from the +// environment. Returns (nil, false) if OAUTH_BASE_URL is unset — the caller +// wires this as "OAuth disabled, endpoints return 503". +func oauthConfigFromEnv() (*oauthProxy, bool) { + base := os.Getenv("OAUTH_BASE_URL") + if base == "" { + return nil, false + } + services := map[string]oauthServiceConfig{} + if id := os.Getenv("MAL_CLIENT_ID"); id != "" { + services["mal"] = oauthServiceConfig{ + ClientID: id, + AuthorizeURL: "https://myanimelist.net/v1/oauth2/authorize", + TokenURL: "https://myanimelist.net/v1/oauth2/token", + UsePKCE: true, + PKCEMethod: "plain", // MAL rejects S256 despite RFC 7636 + } + } + if id := os.Getenv("ANILIST_CLIENT_ID"); id != "" { + services["anilist"] = oauthServiceConfig{ + ClientID: id, + ClientSecret: os.Getenv("ANILIST_CLIENT_SECRET"), + AuthorizeURL: "https://anilist.co/api/v2/oauth/authorize", + TokenURL: "https://anilist.co/api/v2/oauth/token", + } + } + return newOAuthProxy(base, services), true +} + +// registerOAuthRoutes registers all /auth/* handlers. If p is nil (no env +// config), all paths 503 so the integration page clearly says "not configured". +func registerOAuthRoutes(mux *http.ServeMux, p *oauthProxy) { + if p == nil { + mux.HandleFunc("/auth/", func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "OAuth proxy not configured", http.StatusServiceUnavailable) + }) + return + } + mux.HandleFunc("/auth/start", p.handleStart) + mux.HandleFunc("/auth/result", p.handleResult) + mux.HandleFunc("/auth/done", p.handleDone) + mux.HandleFunc("/auth/", p.handleAuthRoot) +} + +// handleAuthRoot dispatches /auth/... paths that aren't served by their own +// registered handler. Covers /auth/:service and /auth/:service/callback. +func (p *oauthProxy) handleAuthRoot(w http.ResponseWriter, r *http.Request) { + rest := strings.TrimPrefix(r.URL.Path, "/auth/") + parts := strings.SplitN(rest, "/", 2) + if len(parts) == 0 || parts[0] == "" { + http.NotFound(w, r) + return + } + service := parts[0] + if len(parts) == 1 { + p.handleAuthorize(w, r, service) + return + } + if parts[1] == "callback" { + p.handleCallback(w, r, service) + return + } + http.NotFound(w, r) +} + +// POST /auth/start body={"service":"mal"|"anilist"} +// Response: {"session":"...","url":"https://.../auth/:service?session=...","expiresIn":600} +func (p *oauthProxy) handleStart(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + ip := clientIP(r) + if !p.ipAllow(ip) { + http.Error(w, "Rate limited", http.StatusTooManyRequests) + return + } + + var body struct { + Service string `json:"service"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 512)).Decode(&body); err != nil { + http.Error(w, "Invalid JSON", http.StatusBadRequest) + return + } + cfg, ok := p.services[body.Service] + if !ok { + http.Error(w, "Unknown service", http.StatusBadRequest) + return + } + + // Generate tokens outside the map lock — crypto/rand syscalls would + // otherwise serialize concurrent /auth/start calls. + sess := &oauthSession{ + id: randToken(oauthSessionIDBytes), + service: body.Service, + createdAt: time.Now(), + done: make(chan struct{}), + } + if cfg.UsePKCE { + sess.codeVerifier = randPKCEVerifier() + } + + p.mu.Lock() + if len(p.sessions) >= oauthMaxSessions { + p.mu.Unlock() + http.Error(w, "Server busy", http.StatusServiceUnavailable) + return + } + p.sessions[sess.id] = sess + p.mu.Unlock() + + resp := map[string]any{ + "session": sess.id, + "url": fmt.Sprintf("%s/auth/%s?session=%s", p.baseURL, url.PathEscape(body.Service), url.QueryEscape(sess.id)), + "expiresIn": int(oauthSessionTTL.Seconds()), + } + writeJSON(w, http.StatusOK, resp) +} + +// GET /auth/:service?session=X → 302 upstream authorize URL +func (p *oauthProxy) handleAuthorize(w http.ResponseWriter, r *http.Request, service string) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + cfg, ok := p.services[service] + if !ok { + http.NotFound(w, r) + return + } + sessionID := r.URL.Query().Get("session") + p.mu.Lock() + sess := p.sessions[sessionID] + p.mu.Unlock() + if sess == nil || sess.service != service { + renderErrorPage(w, http.StatusNotFound, "This sign-in link is no longer valid. Start again from Plezy.") + return + } + + q := url.Values{ + "response_type": {"code"}, + "client_id": {cfg.ClientID}, + "redirect_uri": {p.redirectURI(service)}, + "state": {sess.id}, + } + if cfg.Scopes != "" { + q.Set("scope", cfg.Scopes) + } + if cfg.UsePKCE { + q.Set("code_challenge", sess.codeVerifier) // plain method ⇒ challenge == verifier + q.Set("code_challenge_method", cfg.PKCEMethod) + } + http.Redirect(w, r, cfg.AuthorizeURL+"?"+q.Encode(), http.StatusFound) +} + +// GET /auth/:service/callback?code=...&state=... → exchange, park, render page +func (p *oauthProxy) handleCallback(w http.ResponseWriter, r *http.Request, service string) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + cfg, ok := p.services[service] + if !ok { + http.NotFound(w, r) + return + } + q := r.URL.Query() + state := q.Get("state") + p.mu.Lock() + sess := p.sessions[state] + p.mu.Unlock() + if sess == nil || sess.service != service { + renderErrorPage(w, http.StatusNotFound, "This sign-in link is no longer valid. Start again from Plezy.") + return + } + + if upstreamErr := q.Get("error"); upstreamErr != "" { + sess.complete(oauthTokenResult{Error: upstreamErr}) + renderErrorPage(w, http.StatusOK, "Sign-in was cancelled.") + return + } + code := q.Get("code") + if code == "" { + sess.complete(oauthTokenResult{Error: "missing_code"}) + renderErrorPage(w, http.StatusBadRequest, "Sign-in response was incomplete. Please try again.") + return + } + + tok, err := p.exchangeCode(r.Context(), cfg, service, sess, code) + if err != nil { + log.Printf("oauth: %s token exchange failed: %v", service, err) + sess.complete(oauthTokenResult{Error: "exchange_failed"}) + renderErrorPage(w, http.StatusBadGateway, "Couldn't complete sign-in. Please try again.") + return + } + sess.complete(tok) + renderSuccessPage(w) +} + +// GET /auth/result?session=X → long-poll, returns tokens on success +func (p *oauthProxy) handleResult(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + sessionID := r.URL.Query().Get("session") + p.mu.Lock() + sess := p.sessions[sessionID] + p.mu.Unlock() + if sess == nil { + http.Error(w, "Session not found", http.StatusGone) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), oauthResultWait) + defer cancel() + result, err := sess.wait(ctx) + if err != nil { + // Client should retry — session may still receive its callback. + w.WriteHeader(http.StatusNoContent) + return + } + // Session consumed — delete so a retry sees 410 instead of racing another wait. + p.mu.Lock() + delete(p.sessions, sess.id) + p.mu.Unlock() + + if result.Error != "" { + writeJSON(w, http.StatusOK, map[string]any{"error": result.Error}) + return + } + writeJSON(w, http.StatusOK, result) +} + +// GET /auth/done — static success page (Simkl's redirect target). +func (p *oauthProxy) handleDone(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + renderSuccessPage(w) +} + +func (p *oauthProxy) exchangeCode(ctx context.Context, cfg oauthServiceConfig, service string, sess *oauthSession, code string) (oauthTokenResult, error) { + form := url.Values{ + "grant_type": {"authorization_code"}, + "code": {code}, + "client_id": {cfg.ClientID}, + "redirect_uri": {p.redirectURI(service)}, + } + if cfg.ClientSecret != "" { + form.Set("client_secret", cfg.ClientSecret) + } + if cfg.UsePKCE { + form.Set("code_verifier", sess.codeVerifier) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.TokenURL, strings.NewReader(form.Encode())) + if err != nil { + return oauthTokenResult{}, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + resp, err := p.client.Do(req) + if err != nil { + return oauthTokenResult{}, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return oauthTokenResult{}, fmt.Errorf("upstream HTTP %d", resp.StatusCode) + } + + var parsed struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` + } + if err := json.NewDecoder(io.LimitReader(resp.Body, 64*1024)).Decode(&parsed); err != nil { + return oauthTokenResult{}, fmt.Errorf("decode: %w", err) + } + if parsed.AccessToken == "" { + return oauthTokenResult{}, errors.New("missing access_token in upstream response") + } + return oauthTokenResult{ + AccessToken: parsed.AccessToken, + RefreshToken: parsed.RefreshToken, + ExpiresIn: parsed.ExpiresIn, + }, nil +} + +func (p *oauthProxy) redirectURI(service string) string { + return fmt.Sprintf("%s/auth/%s/callback", p.baseURL, service) +} + +// cleanup drops sessions past oauthSessionTTL. Called by the main cleanup loop. +func (p *oauthProxy) cleanup() { + now := time.Now() + p.mu.Lock() + for id, sess := range p.sessions { + if now.Sub(sess.createdAt) > oauthSessionTTL { + delete(p.sessions, id) + } + } + p.mu.Unlock() + + p.ipMu.Lock() + for ip, rl := range p.ipRate { + if rl.stale(now) { + delete(p.ipRate, ip) + } + } + p.ipMu.Unlock() +} + +func (p *oauthProxy) ipAllow(ip string) bool { + p.ipMu.Lock() + defer p.ipMu.Unlock() + rl, ok := p.ipRate[ip] + if !ok { + rl = newRateLimiter(oauthStartBurst, oauthStartRateSustained) + p.ipRate[ip] = rl + } + return rl.allow() +} + +const successPageHTML = `Signed in

Signed in to Plezy

You can close this tab and return to the app.

` + +// Split around the message so CSS `%` literals don't collide with Fprintf verbs. +const errorPagePrefix = `Sign-in failed

Sign-in failed

` +const errorPageSuffix = `

` + +func renderSuccessPage(w http.ResponseWriter) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + io.WriteString(w, successPageHTML) +} + +func renderErrorPage(w http.ResponseWriter, status int, message string) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(status) + io.WriteString(w, errorPagePrefix) + io.WriteString(w, html.EscapeString(message)) + io.WriteString(w, errorPageSuffix) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func randToken(numBytes int) string { + b := make([]byte, numBytes) + if _, err := rand.Read(b); err != nil { + // crypto/rand failing is catastrophic; log.Fatalf matches the style + // in newLogStore for similar unrecoverable init failures. + log.Fatalf("crypto/rand: %v", err) + } + return base64.RawURLEncoding.EncodeToString(b) +} + +// randPKCEVerifier returns a 64-char string from MAL's required alphabet +// (RFC 7636 §4.1 unreserved set). +func randPKCEVerifier() string { + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" + b := make([]byte, oauthPKCEVerifierLen) + if _, err := rand.Read(b); err != nil { + log.Fatalf("crypto/rand: %v", err) + } + for i := range b { + b[i] = alphabet[int(b[i])%len(alphabet)] + } + return string(b) +} diff --git a/server/oauth_test.go b/server/oauth_test.go new file mode 100644 index 00000000..6d19b207 --- /dev/null +++ b/server/oauth_test.go @@ -0,0 +1,551 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + "time" +) + +// mockUpstream runs an httptest server that impersonates MAL/AniList. It +// records the last token-exchange form submission and returns a canned +// access_token/refresh_token response. +type mockUpstream struct { + srv *httptest.Server + mu sync.Mutex + lastForm url.Values + tokenReply string + tokenCode int +} + +// httpGet / httpPost / httpDo wrap the stdlib calls to fail the test on error. +// Keeps test bodies one-liner without tripping `go vet`'s +// "using resp before checking errors" rule. +func httpGet(t *testing.T, url string) *http.Response { + t.Helper() + resp, err := http.Get(url) + if err != nil { + t.Fatalf("GET %s: %v", url, err) + } + return resp +} + +func httpPost(t *testing.T, url, contentType string, body io.Reader) *http.Response { + t.Helper() + resp, err := http.Post(url, contentType, body) + if err != nil { + t.Fatalf("POST %s: %v", url, err) + } + return resp +} + +func httpDo(t *testing.T, req *http.Request) *http.Response { + t.Helper() + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do %s %s: %v", req.Method, req.URL, err) + } + return resp +} + +func newMockUpstream(t *testing.T) *mockUpstream { + t.Helper() + m := &mockUpstream{ + tokenReply: `{"access_token":"tok-abc","refresh_token":"ref-xyz","expires_in":2678400}`, + tokenCode: http.StatusOK, + } + m.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/oauth/authorize": + // Unused in tests — we assert on the 302 Location from our proxy. + w.WriteHeader(http.StatusOK) + case "/oauth/token": + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + m.mu.Lock() + m.lastForm = r.PostForm + code := m.tokenCode + reply := m.tokenReply + m.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + io.WriteString(w, reply) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(m.srv.Close) + return m +} + +func (m *mockUpstream) setReply(code int, body string) { + m.mu.Lock() + defer m.mu.Unlock() + m.tokenCode = code + m.tokenReply = body +} + +func (m *mockUpstream) form() url.Values { + m.mu.Lock() + defer m.mu.Unlock() + return m.lastForm +} + +// newOAuthHarness boots a relay-less httptest server that mounts /auth/* only, +// with `mal` and `anilist` services pointed at a shared mock upstream. +type oauthHarness struct { + proxy *oauthProxy + srv *httptest.Server + base string + upstream *mockUpstream +} + +func newOAuthHarness(t *testing.T) *oauthHarness { + t.Helper() + up := newMockUpstream(t) + proxy := newOAuthProxy("http://placeholder", map[string]oauthServiceConfig{ + "mal": { + ClientID: "mal-id", + AuthorizeURL: up.srv.URL + "/oauth/authorize", + TokenURL: up.srv.URL + "/oauth/token", + UsePKCE: true, + PKCEMethod: "plain", + }, + "anilist": { + ClientID: "anilist-id", + ClientSecret: "anilist-secret", + AuthorizeURL: up.srv.URL + "/oauth/authorize", + TokenURL: up.srv.URL + "/oauth/token", + }, + }) + mux := http.NewServeMux() + registerOAuthRoutes(mux, proxy) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + // Rewire baseURL to the real httptest URL so redirect_uri computes correctly. + proxy.baseURL = srv.URL + return &oauthHarness{proxy: proxy, srv: srv, base: srv.URL, upstream: up} +} + +func (h *oauthHarness) startSession(t *testing.T, service, ip string) (sessionID, qrURL string) { + t.Helper() + body, _ := json.Marshal(map[string]string{"service": service}) + req, _ := http.NewRequest(http.MethodPost, h.base+"/auth/start", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + if ip != "" { + req.Header.Set("X-Forwarded-For", ip) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("start: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("start status=%d", resp.StatusCode) + } + var out struct { + Session string `json:"session"` + URL string `json:"url"` + ExpiresIn int `json:"expiresIn"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatalf("decode: %v", err) + } + if out.Session == "" || out.URL == "" { + t.Fatalf("empty session/url: %+v", out) + } + return out.Session, out.URL +} + +// ====== /auth/start ====== + +func TestOAuthStartReturnsSessionAndURL(t *testing.T) { + h := newOAuthHarness(t) + session, qr := h.startSession(t, "mal", "1.2.3.4") + if !strings.HasPrefix(qr, h.base+"/auth/mal?session=") { + t.Fatalf("url=%q doesn't look like the authorize start URL", qr) + } + if !strings.Contains(qr, url.QueryEscape(session)) { + t.Fatalf("url=%q missing session token", qr) + } +} + +func TestOAuthStartRejectsUnknownService(t *testing.T) { + h := newOAuthHarness(t) + body, _ := json.Marshal(map[string]string{"service": "nope"}) + resp := httpPost(t, h.base+"/auth/start", "application/json", bytes.NewReader(body)) + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status=%d want 400", resp.StatusCode) + } +} + +func TestOAuthStartRejectsInvalidJSON(t *testing.T) { + h := newOAuthHarness(t) + resp := httpPost(t, h.base+"/auth/start", "application/json", strings.NewReader("not json")) + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status=%d want 400", resp.StatusCode) + } +} + +func TestOAuthStartRateLimitedPerIP(t *testing.T) { + h := newOAuthHarness(t) + ip := "5.5.5.5" + for i := 0; i < oauthStartBurst; i++ { + h.startSession(t, "mal", ip) // should all succeed + } + body, _ := json.Marshal(map[string]string{"service": "mal"}) + req, err := http.NewRequest(http.MethodPost, h.base+"/auth/start", bytes.NewReader(body)) + if err != nil { + t.Fatalf("new request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Forwarded-For", ip) + resp := httpDo(t, req) + defer resp.Body.Close() + if resp.StatusCode != http.StatusTooManyRequests { + t.Fatalf("status=%d want 429", resp.StatusCode) + } +} + +func TestOAuthStartMethodNotAllowed(t *testing.T) { + h := newOAuthHarness(t) + resp := httpGet(t, h.base+"/auth/start") + defer resp.Body.Close() + if resp.StatusCode != http.StatusMethodNotAllowed { + t.Fatalf("status=%d want 405", resp.StatusCode) + } +} + +// ====== /auth/:service (authorize redirect) ====== + +func TestOAuthAuthorizeMALRedirectIncludesPKCE(t *testing.T) { + h := newOAuthHarness(t) + sess, _ := h.startSession(t, "mal", "1.1.1.1") + + client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + resp, err := client.Get(h.base + "/auth/mal?session=" + url.QueryEscape(sess)) + if err != nil { + t.Fatalf("get: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusFound { + t.Fatalf("status=%d want 302", resp.StatusCode) + } + loc, err := url.Parse(resp.Header.Get("Location")) + if err != nil { + t.Fatalf("parse Location: %v", err) + } + q := loc.Query() + if q.Get("client_id") != "mal-id" { + t.Errorf("client_id=%q", q.Get("client_id")) + } + if q.Get("response_type") != "code" { + t.Errorf("response_type=%q", q.Get("response_type")) + } + if q.Get("state") != sess { + t.Errorf("state=%q, want session %q", q.Get("state"), sess) + } + if q.Get("code_challenge_method") != "plain" { + t.Errorf("code_challenge_method=%q, want plain", q.Get("code_challenge_method")) + } + if q.Get("code_challenge") == "" { + t.Error("code_challenge missing") + } + if !strings.HasSuffix(q.Get("redirect_uri"), "/auth/mal/callback") { + t.Errorf("redirect_uri=%q", q.Get("redirect_uri")) + } +} + +func TestOAuthAuthorizeAnilistRedirectOmitsPKCE(t *testing.T) { + h := newOAuthHarness(t) + sess, _ := h.startSession(t, "anilist", "1.1.1.2") + client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + resp, err := client.Get(h.base + "/auth/anilist?session=" + url.QueryEscape(sess)) + if err != nil { + t.Fatalf("get: %v", err) + } + defer resp.Body.Close() + loc, _ := url.Parse(resp.Header.Get("Location")) + q := loc.Query() + if q.Get("code_challenge") != "" { + t.Errorf("anilist redirect should not include code_challenge, got %q", q.Get("code_challenge")) + } +} + +func TestOAuthAuthorizeUnknownSessionRendersError(t *testing.T) { + h := newOAuthHarness(t) + resp := httpGet(t, h.base+"/auth/mal?session=bogus") + defer resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("status=%d want 404", resp.StatusCode) + } + body, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(body), "no longer valid") { + t.Errorf("expected error page html, got: %s", body) + } +} + +func TestOAuthAuthorizeWrongServiceRejected(t *testing.T) { + h := newOAuthHarness(t) + sess, _ := h.startSession(t, "mal", "1.1.1.3") + // Try to use the MAL session against the AniList authorize endpoint. + resp := httpGet(t, h.base+"/auth/anilist?session="+url.QueryEscape(sess)) + defer resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("status=%d want 404", resp.StatusCode) + } +} + +// ====== /auth/:service/callback + /auth/result ====== + +func TestOAuthCallbackExchangesCodeAndResultReturnsTokens(t *testing.T) { + h := newOAuthHarness(t) + sess, _ := h.startSession(t, "mal", "2.2.2.1") + + resultCh := make(chan map[string]any, 1) + go func() { + resp, err := http.Get(h.base + "/auth/result?session=" + url.QueryEscape(sess)) + if err != nil { + resultCh <- map[string]any{"_err": err.Error()} + return + } + defer resp.Body.Close() + var m map[string]any + _ = json.NewDecoder(resp.Body).Decode(&m) + resultCh <- m + }() + + // Hit the callback as the upstream browser would. + cbURL := fmt.Sprintf("%s/auth/mal/callback?code=CODE123&state=%s", h.base, url.QueryEscape(sess)) + resp, err := http.Get(cbURL) + if err != nil { + t.Fatalf("callback: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("callback status=%d", resp.StatusCode) + } + + // Upstream should have been called with PKCE + code. + form := h.upstream.form() + if form.Get("code") != "CODE123" { + t.Errorf("upstream code=%q", form.Get("code")) + } + if form.Get("code_verifier") == "" { + t.Error("upstream missing code_verifier (PKCE)") + } + if form.Get("grant_type") != "authorization_code" { + t.Errorf("grant_type=%q", form.Get("grant_type")) + } + + select { + case got := <-resultCh: + if got["accessToken"] != "tok-abc" { + t.Errorf("accessToken=%v want tok-abc", got["accessToken"]) + } + if got["refreshToken"] != "ref-xyz" { + t.Errorf("refreshToken=%v want ref-xyz", got["refreshToken"]) + } + case <-time.After(3 * time.Second): + t.Fatal("result never returned") + } +} + +func TestOAuthCallbackUpstreamError(t *testing.T) { + h := newOAuthHarness(t) + h.upstream.setReply(http.StatusBadRequest, `{"error":"invalid_grant"}`) + sess, _ := h.startSession(t, "mal", "2.2.2.2") + + resultCh := make(chan map[string]any, 1) + go func() { + resp, err := http.Get(h.base + "/auth/result?session=" + url.QueryEscape(sess)) + if err != nil { + resultCh <- map[string]any{"_err": err.Error()} + return + } + defer resp.Body.Close() + var m map[string]any + _ = json.NewDecoder(resp.Body).Decode(&m) + resultCh <- m + }() + + resp := httpGet(t, fmt.Sprintf("%s/auth/mal/callback?code=CODE&state=%s", h.base, url.QueryEscape(sess))) + resp.Body.Close() + + select { + case got := <-resultCh: + if got["error"] != "exchange_failed" { + t.Errorf("expected error=exchange_failed, got %v", got) + } + case <-time.After(3 * time.Second): + t.Fatal("result never returned") + } +} + +func TestOAuthCallbackUserCancelled(t *testing.T) { + h := newOAuthHarness(t) + sess, _ := h.startSession(t, "mal", "2.2.2.3") + + resultCh := make(chan map[string]any, 1) + go func() { + resp, err := http.Get(h.base + "/auth/result?session=" + url.QueryEscape(sess)) + if err != nil { + resultCh <- map[string]any{"_err": err.Error()} + return + } + defer resp.Body.Close() + var m map[string]any + _ = json.NewDecoder(resp.Body).Decode(&m) + resultCh <- m + }() + + resp := httpGet(t, fmt.Sprintf("%s/auth/mal/callback?error=access_denied&state=%s", h.base, url.QueryEscape(sess))) + resp.Body.Close() + + select { + case got := <-resultCh: + if got["error"] != "access_denied" { + t.Errorf("expected error=access_denied, got %v", got) + } + case <-time.After(3 * time.Second): + t.Fatal("result never returned") + } +} + +func TestOAuthCallbackUnknownSessionIgnored(t *testing.T) { + h := newOAuthHarness(t) + resp := httpGet(t, h.base+"/auth/mal/callback?code=X&state=bogus") + defer resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("status=%d want 404", resp.StatusCode) + } +} + +func TestOAuthResultUnknownSession(t *testing.T) { + h := newOAuthHarness(t) + resp := httpGet(t, h.base+"/auth/result?session=nope") + defer resp.Body.Close() + if resp.StatusCode != http.StatusGone { + t.Fatalf("status=%d want 410", resp.StatusCode) + } +} + +func TestOAuthResultConsumedSecondCallIsGone(t *testing.T) { + h := newOAuthHarness(t) + sess, _ := h.startSession(t, "mal", "3.3.3.1") + + // Pre-seat the result so the first /auth/result returns immediately. + h.proxy.mu.Lock() + h.proxy.sessions[sess].complete(oauthTokenResult{AccessToken: "tok"}) + h.proxy.mu.Unlock() + + r1 := httpGet(t, h.base+"/auth/result?session="+url.QueryEscape(sess)) + r1.Body.Close() + if r1.StatusCode != http.StatusOK { + t.Fatalf("first result status=%d", r1.StatusCode) + } + // After consumption the session is deleted; second call sees unknown session. + r2 := httpGet(t, h.base+"/auth/result?session="+url.QueryEscape(sess)) + r2.Body.Close() + if r2.StatusCode != http.StatusGone { + t.Fatalf("second result status=%d want 410", r2.StatusCode) + } +} + +// ====== Cleanup ====== + +func TestOAuthCleanupExpiresOldSessions(t *testing.T) { + h := newOAuthHarness(t) + sess, _ := h.startSession(t, "mal", "4.4.4.1") + + h.proxy.mu.Lock() + h.proxy.sessions[sess].createdAt = time.Now().Add(-2 * oauthSessionTTL) + h.proxy.mu.Unlock() + + h.proxy.cleanup() + + h.proxy.mu.Lock() + _, exists := h.proxy.sessions[sess] + h.proxy.mu.Unlock() + if exists { + t.Fatal("expired session should have been cleaned up") + } +} + +// ====== /auth/done ====== + +func TestOAuthDoneRendersSuccessPage(t *testing.T) { + h := newOAuthHarness(t) + resp := httpGet(t, h.base+"/auth/done") + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status=%d", resp.StatusCode) + } + body, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(body), "Signed in to Plezy") { + t.Errorf("body missing success message: %s", body) + } +} + +// ====== Disabled proxy returns 503 ====== + +func TestOAuthRoutesReturn503WhenDisabled(t *testing.T) { + mux := http.NewServeMux() + registerOAuthRoutes(mux, nil) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + resp := httpGet(t, srv.URL+"/auth/start") + resp.Body.Close() + if resp.StatusCode != http.StatusServiceUnavailable { + t.Errorf("status=%d want 503", resp.StatusCode) + } +} + +// ====== Path dispatch ====== + +func TestOAuthAuthRootRejectsBadPaths(t *testing.T) { + h := newOAuthHarness(t) + for _, path := range []string{"/auth/mal/weird", "/auth/unknown", "/auth/mal/callback/extra"} { + resp := httpGet(t, h.base+path) + resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Errorf("%s: status=%d want 404", path, resp.StatusCode) + } + } +} + +// ====== Long-poll timeout ====== + +func TestOAuthResultBlocksUntilCancel(t *testing.T) { + // Pending sessions must NOT respond immediately; the long-poll contract is + // that /auth/result blocks until the session completes or the client + // cancels. The 204-after-server-timeout path takes 50s so isn't asserted. + h := newOAuthHarness(t) + sess, _ := h.startSession(t, "mal", "5.5.5.1") + + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, h.base+"/auth/result?session="+url.QueryEscape(sess), nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + resp, err := http.DefaultClient.Do(req) + if err == nil { + resp.Body.Close() + t.Fatalf("expected client-side cancel, got status=%d", resp.StatusCode) + } +}