diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-22 23:30:05 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-22 23:30:05 +0300 |
| commit | 8d450ee93f095cfd2c5374b181d34ead9b49d502 (patch) | |
| tree | 40f303e291df57e73d0ae5e87bcfac0afe0c80c4 | |
| parent | 9cd7ccc5d1150bf841a3961df9cb1e8a5d25abfe (diff) | |
Pass session cookie to ExoPlayer/just_audio for authenticated streaming
The audio/video players spawn a localhost proxy (just_audio) or call
ExoPlayer directly (video_player) using their own HTTP stack, which does
not share Dio's cookie jar. Without the session cookie those requests
hit the stream endpoint anonymously and fail with 401.
Expose the Dio CookieJar via a Riverpod provider (cookieJarProvider) and
attach a Cookie header (alongside the existing Authorization: Bearer) to
both AudioSource.uri and VideoPlayerController.networkUrl.
Also enable android:usesCleartextTraffic="true" on the Application —
just_audio's headers-injection proxy listens on 127.0.0.1 and Android
28+ blocks cleartext to it without the explicit opt-in.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| -rw-r--r-- | player-android/android/app/src/main/AndroidManifest.xml | 3 | ||||
| -rw-r--r-- | player-android/lib/api/dio_client.dart | 39 | ||||
| -rw-r--r-- | player-android/lib/providers/api_client_provider.dart | 21 | ||||
| -rw-r--r-- | player-android/lib/screens/audio_player_screen.dart | 26 | ||||
| -rw-r--r-- | player-android/lib/screens/video_player_screen.dart | 11 |
5 files changed, 72 insertions, 28 deletions
diff --git a/player-android/android/app/src/main/AndroidManifest.xml b/player-android/android/app/src/main/AndroidManifest.xml index 81db32c..00d1b91 100644 --- a/player-android/android/app/src/main/AndroidManifest.xml +++ b/player-android/android/app/src/main/AndroidManifest.xml @@ -12,7 +12,8 @@ <application android:label="Player" android:name="${applicationName}" - android:icon="@drawable/ic_launcher"> + android:icon="@drawable/ic_launcher" + android:usesCleartextTraffic="true"> <activity android:name=".MainActivity" android:exported="true" diff --git a/player-android/lib/api/dio_client.dart b/player-android/lib/api/dio_client.dart index c988cf0..7a46885 100644 --- a/player-android/lib/api/dio_client.dart +++ b/player-android/lib/api/dio_client.dart @@ -113,32 +113,48 @@ class _UnauthorizedInterceptor extends Interceptor { /// Callers own the returned [Dio] and may add further interceptors on top. /// Separating construction from usage (SRP) keeps this class testable. class DioClient { - DioClient({ + factory DioClient({ required Uri baseUrl, required TokenStorage storage, required GlobalKey<NavigatorState> navigatorKey, String loginRoute = '/login', BaseOptions? baseOptions, - }) : _dio = _buildDio( - baseUrl: baseUrl, - storage: storage, - navigatorKey: navigatorKey, - loginRoute: loginRoute, - baseOptions: baseOptions, - ); + }) { + final jar = CookieJar(); + final dio = _buildDio( + baseUrl: baseUrl, + storage: storage, + navigatorKey: navigatorKey, + loginRoute: loginRoute, + baseOptions: baseOptions, + cookieJar: jar, + ); + return DioClient._(dio: dio, cookieJar: jar); + } + + DioClient._({required Dio dio, required CookieJar cookieJar}) + : _dio = dio, + _cookieJar = cookieJar; final Dio _dio; + final CookieJar _cookieJar; /// Exposes the underlying [Dio] so that [PlayerApiClient] can issue typed /// requests without re-implementing the interceptor plumbing. Dio get dio => _dio; + /// Exposes the cookie jar so consumers that bypass Dio (e.g. ExoPlayer via + /// just_audio, video_player, CachedNetworkImage) can still authenticate + /// against the session-cookie-protected media endpoints. + CookieJar get cookieJar => _cookieJar; + static Dio _buildDio({ required Uri baseUrl, required TokenStorage storage, required GlobalKey<NavigatorState> navigatorKey, required String loginRoute, BaseOptions? baseOptions, + required CookieJar cookieJar, }) { final options = (baseOptions ?? BaseOptions()).copyWith( baseUrl: baseUrl.toString(), @@ -147,13 +163,6 @@ class DioClient { responseType: ResponseType.json, ); - // The server's /api/v1/auth/login sets an HttpOnly Set-Cookie (session=...). - // Browsers persist this automatically; on mobile we attach a CookieJar so - // Dio replays the cookie on subsequent requests. Without this, every call - // after login returns 401 because Dio discards cookies by default. - // In-memory is sufficient: logout clears it, and we persist the bearer - // token (for API-token auth) separately via flutter_secure_storage. - final cookieJar = CookieJar(); return Dio(options) ..interceptors.addAll([ // Cookie manager runs first so the session cookie is replayed before diff --git a/player-android/lib/providers/api_client_provider.dart b/player-android/lib/providers/api_client_provider.dart index 9a3ca8d..27bd62f 100644 --- a/player-android/lib/providers/api_client_provider.dart +++ b/player-android/lib/providers/api_client_provider.dart @@ -1,3 +1,4 @@ +import 'package:cookie_jar/cookie_jar.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../api/dio_client.dart'; @@ -36,10 +37,13 @@ final tokenStorageProvider = Provider<TokenStorage>((ref) { /// Depends on [tokenStorageProvider] and [navigatorKey] (both singletons) so /// the same [Dio] instance is reused for every call site — avoiding redundant /// interceptor stacks. -final apiClientProvider = Provider<PlayerApiClient>((ref) { +// Single shared DioClient instance: cached as a Riverpod Provider so both the +// API client and the cookie-jar provider observe the same cookie store. The +// API client uses Dio; ExoPlayer/video_player/CachedNetworkImage bypass Dio +// and need the cookie jar to attach the session cookie manually. +final _dioClientProvider = Provider<DioClient>((ref) { final storage = ref.watch(tokenStorageProvider); - - final dioClient = DioClient( + return DioClient( baseUrl: Uri.parse(kPlayerBaseUrl), storage: storage, // Share the navigator key with go_router so 401 redirects go through the @@ -47,10 +51,19 @@ final apiClientProvider = Provider<PlayerApiClient>((ref) { navigatorKey: navigatorKey, loginRoute: '/login', ); +}); +final apiClientProvider = Provider<PlayerApiClient>((ref) { // Use DioPlayerApiClient — the concrete implementation that maps every // PlayerApiClient method to a real HTTP call via Dio. The base class now // acts as the public interface (dependency inversion); callers depend on // PlayerApiClient, not on this concrete class. - return DioPlayerApiClient(dio: dioClient.dio); + return DioPlayerApiClient(dio: ref.watch(_dioClientProvider).dio); +}); + +/// Provides the same [CookieJar] backing [apiClientProvider]'s Dio stack so +/// non-Dio HTTP clients (ExoPlayer, video_player, CachedNetworkImage) can +/// authenticate streaming/thumbnail requests using the same session cookie. +final cookieJarProvider = Provider<CookieJar>((ref) { + return ref.watch(_dioClientProvider).cookieJar; }); diff --git a/player-android/lib/screens/audio_player_screen.dart b/player-android/lib/screens/audio_player_screen.dart index 9e28062..4f05abf 100644 --- a/player-android/lib/screens/audio_player_screen.dart +++ b/player-android/lib/screens/audio_player_screen.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:cookie_jar/cookie_jar.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:just_audio/just_audio.dart'; @@ -131,11 +132,12 @@ class _AudioPlayerScreenState extends ConsumerState<AudioPlayerScreen> { final player = handler.player; final client = ref.read(apiClientProvider); final storage = ref.read(tokenStorageProvider); + final cookieJar = ref.read(cookieJarProvider); final mediaIdInt = int.tryParse(widget.mediaId) ?? 0; final url = widget.mediaUrl ?? client.streamUrl(mediaIdInt); - // Step 1–2: build auth headers. - final headers = await _buildAuthHeaders(storage); + // Step 1–2: build auth headers (Bearer + session cookie). + final headers = await _buildAuthHeaders(storage, cookieJar, Uri.parse(url)); if (!mounted) return; // Step 3: load the authenticated source; show error UI on failure. @@ -162,14 +164,26 @@ class _AudioPlayerScreenState extends ConsumerState<AudioPlayerScreen> { _startProgressTicker(mediaIdInt, client, player, queue); } - /// Reads the bearer token and returns the `Authorization` header map. + /// Builds the headers map for an authenticated stream request. /// - /// Returns an empty map when no token is stored so the source can still be - /// loaded (e.g., public streams or during tests). - Future<Map<String, String>> _buildAuthHeaders(TokenStorage storage) async { + /// just_audio runs a localhost proxy that forwards these headers to + /// ExoPlayer's underlying HTTP request, which is how we authenticate against + /// the session-cookie-protected `/api/v1/media/{id}/stream` endpoint without + /// sharing Dio's HTTP stack. Both Bearer (for API-token auth) and Cookie + /// (for session auth) are attached so either auth scheme works. + Future<Map<String, String>> _buildAuthHeaders( + TokenStorage storage, + CookieJar jar, + Uri url, + ) async { final token = await storage.readToken(); + final cookies = await jar.loadForRequest(url); + final cookieHeader = cookies + .map((c) => '${c.name}=${c.value}') + .join('; '); return <String, String>{ if (token != null && token.isNotEmpty) 'Authorization': 'Bearer $token', + if (cookieHeader.isNotEmpty) 'Cookie': cookieHeader, }; } diff --git a/player-android/lib/screens/video_player_screen.dart b/player-android/lib/screens/video_player_screen.dart index 763168d..2172a12 100644 --- a/player-android/lib/screens/video_player_screen.dart +++ b/player-android/lib/screens/video_player_screen.dart @@ -116,19 +116,26 @@ class _VideoPlayerScreenState extends ConsumerState<VideoPlayerScreen> { final client = ref.read(apiClientProvider); final storage = ref.read(tokenStorageProvider); + final cookieJar = ref.read(cookieJarProvider); final mediaIdInt = int.tryParse(widget.mediaId) ?? 0; // Step 1: resolve the stream URL — prefer the route-extra URL so the // calling screen can forward a pre-computed URL; fall back to streamUrl. final url = widget.mediaUrl ?? client.streamUrl(mediaIdInt); - // Step 2: read the bearer token so the native player can authenticate - // without routing bytes through Dart (performance and correctness). + // Step 2: read the auth artefacts so the native player can authenticate + // without routing bytes through Dart. Both Bearer (API-token auth) and + // Cookie (session auth) headers are attached because ExoPlayer has its + // own HTTP stack and does not share Dio's cookie jar. final token = await storage.readToken(); + final cookies = await cookieJar.loadForRequest(Uri.parse(url)); if (!mounted) return; + final cookieHeader = + cookies.map((c) => '${c.name}=${c.value}').join('; '); final headers = <String, String>{ if (token != null && token.isNotEmpty) 'Authorization': 'Bearer $token', + if (cookieHeader.isNotEmpty) 'Cookie': cookieHeader, }; // Step 3: create and initialise the VideoPlayerController. |
