diff --git a/docs/code_snippets/02_02_authentication.dart b/docs/code_snippets/02_02_authentication.dart new file mode 100644 index 00000000..c77568c3 --- /dev/null +++ b/docs/code_snippets/02_02_authentication.dart @@ -0,0 +1,58 @@ +import 'package:stream_feeds/stream_feeds.dart'; + +Future regularUserLogin() async { + // Regular user: provide a JWT token (from your server). + final client = StreamFeedsClient( + apiKey: '', + user: const User(id: 'alice'), + tokenProvider: TokenProvider.static(UserToken('')), + ); + await client.connect(); +} + +Future dynamicTokenProvider() async { + // Dynamic token provider: fetches a new token from your server + // when the current one expires. + final client = StreamFeedsClient( + apiKey: '', + user: const User(id: 'alice'), + tokenProvider: TokenProvider.dynamic((userId) async { + // Fetch a fresh JWT for `userId` from your backend. + final token = await fetchTokenFromYourServer(userId); + return UserToken(token); + }), + ); + await client.connect(); +} + +// Placeholder for your server token fetch +Future fetchTokenFromYourServer(String userId) async => ''; + +Future guestUserLogin() async { + // Guest user: the SDK automatically calls POST /api/v2/guest to obtain + // a temporary JWT — no tokenProvider is needed. + // Guest users have full read/write access and a real WebSocket connection, + // but their session is temporary and not tied to a persistent account. + final client = StreamFeedsClient( + apiKey: '', + user: User.guest('guest-${DateTime.now().millisecondsSinceEpoch}'), + ); + await client.connect(); // Guest JWT is fetched automatically on connect. + + final feed = client.feed(group: 'user', id: client.user.id); + await feed.getOrCreate(); +} + +Future anonymousUserLogin() async { + // Anonymous user: read-only access with no JWT or WebSocket connection. + // Use this for public feeds that don't require authentication. + // Note: calling connect() throws for anonymous users. + final client = StreamFeedsClient( + apiKey: '', + user: const User.anonymous(), + ); + + // Read public feed data without connecting. + final feed = client.feed(group: 'user', id: 'alice'); + await feed.getOrCreate(); +} diff --git a/melos.yaml b/melos.yaml index 042faa0d..c721d276 100644 --- a/melos.yaml +++ b/melos.yaml @@ -47,7 +47,8 @@ command: shared_preferences: ^2.5.3 state_notifier: ^1.0.0 stream_feeds: ^0.5.1 - stream_core: ^0.4.0 + stream_core: + path: /Users/renefloor/Documents/github/stream-core-flutter/packages/stream_core video_player: ^2.10.0 uuid: ^4.5.1 web_socket_channel: ^3.0.0 diff --git a/packages/stream_feeds/CHANGELOG.md b/packages/stream_feeds/CHANGELOG.md index 1b00fdc3..07e53263 100644 --- a/packages/stream_feeds/CHANGELOG.md +++ b/packages/stream_feeds/CHANGELOG.md @@ -1,5 +1,8 @@ ## Upcoming +### Improvements +- Guest users (`User.guest(id)`) now obtain a real JWT by calling `POST /api/v2/guest` during `connect()`, giving them a full authenticated session with WebSocket support. Previously guest users fell back to the anonymous token which prevented WS connectivity. If the backend assigns a different id to the guest user, `client.user` is updated to match it. + ### New fields - Added `isRead` and `isSeen` fields to `ActivityData` and `AggregatedActivityData` for notification-feed read/seen state. - Added `friendReactionCount` and `friendReactions` fields to `ActivityData` to expose reactions from friends. diff --git a/packages/stream_feeds/lib/src/client/feeds_client_impl.dart b/packages/stream_feeds/lib/src/client/feeds_client_impl.dart index 34c8c176..c7bb161c 100644 --- a/packages/stream_feeds/lib/src/client/feeds_client_impl.dart +++ b/packages/stream_feeds/lib/src/client/feeds_client_impl.dart @@ -61,10 +61,17 @@ import '../state/query/polls_query.dart'; import '../ws/feeds_ws_event.dart'; import 'endpoint_config.dart'; +// Shared REST client options for both the main and guest-token HTTP clients. +BaseOptions _restApiOptions(EndpointConfig endpointConfig) => BaseOptions( + baseUrl: endpointConfig.baseFeedsUrl, + connectTimeout: const Duration(seconds: 6), + receiveTimeout: const Duration(seconds: 6), +); + class StreamFeedsClientImpl implements StreamFeedsClient { StreamFeedsClientImpl({ required this.apiKey, - required this.user, + required User user, this.config = const FeedsConfig(), TokenProvider? tokenProvider, RetryStrategy? retryStrategy, @@ -73,7 +80,8 @@ class StreamFeedsClientImpl implements StreamFeedsClient { List? reconnectionPolicies, WebSocketProvider? wsProvider, api.DefaultApi? feedsRestApi, - }) { + api.DefaultApi? guestRestApi, + }) : _user = user { // TODO: Make this configurable const endpointConfig = EndpointConfig.production; @@ -84,11 +92,21 @@ class StreamFeedsClientImpl implements StreamFeedsClient { (UserType.regular, null) => throw ArgumentError( 'TokenProvider must be provided for regular users.', ), - (UserType.anonymous || UserType.guest, _) => TokenProvider.static( + (UserType.anonymous, _) => TokenProvider.static( UserToken.anonymous(userId: user.id), ), + (UserType.guest, _) => _guestTokenProvider( + user: user, + apiKey: apiKey, + endpointConfig: endpointConfig, + guestRestApi: guestRestApi, + ), }; + // For guest users this starts with the originally-requested id and is + // swapped for a manager carrying the server-resolved id once the token + // exchange completes (see `_guestTokenProvider`). `AuthInterceptor` reads + // the manager through a getter, so it always sees the current instance. _tokenManager = TokenManager( userId: user.id, tokenProvider: userTokenProvider, @@ -135,17 +153,13 @@ class StreamFeedsClientImpl implements StreamFeedsClient { final httpClient = StreamCoreHttpClient( - options: BaseOptions( - baseUrl: endpointConfig.baseFeedsUrl, - connectTimeout: const Duration(seconds: 6), - receiveTimeout: const Duration(seconds: 6), - ), + options: _restApiOptions(endpointConfig), ).apply( (client) => client.interceptors.addAll([ ApiKeyInterceptor(apiKey), HeadersInterceptor(_systemEnvironmentManager), if (user.type != UserType.anonymous) connectionIdInterceptor, - AuthInterceptor(client, _tokenManager), + AuthInterceptor(client, () => _tokenManager), const ApiErrorInterceptor(), LoggingInterceptor(requestHeader: true), ]), @@ -184,12 +198,19 @@ class StreamFeedsClientImpl implements StreamFeedsClient { final String apiKey; + // The current user identity. Mutable because a guest user's id/profile may + // be reassigned by the server once the guest token exchange completes; see + // [_guestTokenProvider]. @override - final User user; + User get user => _user; + User _user; final FeedsConfig config; - late final TokenManager _tokenManager; + // Not `final`: for guest users this is swapped for a manager carrying the + // server-resolved user id once the token exchange completes (see + // `_guestTokenProvider`). + late TokenManager _tokenManager; late final StreamWebSocketClient _ws; late final ConnectionRecoveryHandler _connectionRecoveryHandler; @@ -226,21 +247,102 @@ class StreamFeedsClientImpl implements StreamFeedsClient { @override late final ModerationClient moderation; + /// Builds the [TokenProvider] used to obtain a guest JWT. + /// + /// Guest users have no pre-issued token, so one is minted lazily via + /// `POST /api/v2/guest`, called through a dedicated, unauthenticated HTTP + /// client. The backend may return a different id than the one requested, to + /// avoid colliding with an existing user, so [_user] is updated to the + /// server's response to keep the WS handshake and any `client.user` reads in + /// sync with the identity the token actually authenticates as. + /// + /// Once the id is known, [_tokenManager] is swapped for one pinned to that id + /// with a static provider: the guest identity is established once (like an + /// anonymous user) rather than re-minted on every token load, and + /// `AuthInterceptor` — which reads the manager through a getter — picks up + /// the resolved id for the `user_id` query parameter. + TokenProvider _guestTokenProvider({ + required User user, + required String apiKey, + required EndpointConfig endpointConfig, + api.DefaultApi? guestRestApi, + }) { + var guestApi = guestRestApi; + + return TokenProvider.dynamic((_) async { + final guestApiClient = guestApi ??= api.DefaultApi( + StreamCoreHttpClient(options: _restApiOptions(endpointConfig)).apply( + (client) => client.interceptors.addAll([ + ApiKeyInterceptor(apiKey), + HeadersInterceptor(_systemEnvironmentManager), + const ApiErrorInterceptor(), + ]), + ), + ); + + final result = await guestApiClient.createGuest( + createGuestRequest: api.CreateGuestRequest( + user: api.UserRequest( + id: user.id, + name: user.originalName, + image: user.image, + custom: user.custom.isEmpty ? null : user.custom, + ), + ), + ); + final response = result.getOrThrow(); + + _user = User( + id: response.user.id, + name: response.user.name, + image: response.user.image, + role: response.user.role, + type: UserType.guest, + custom: response.user.custom, + ); + + final token = UserToken(response.accessToken); + + // Pin the manager to the resolved id so subsequent REST/WS calls use it + // without re-running the guest exchange. + _tokenManager = TokenManager( + userId: response.user.id, + tokenProvider: TokenProvider.static(token), + ); + + return token; + }); + } + Future _authenticateUser() async { - final userToken = await _tokenManager.getToken(); - - final connectUserRequest = WsAuthMessageRequest( - products: const ['feeds'], - token: userToken.rawValue, - userDetails: ConnectUserDetailsRequest( - id: user.id, - name: user.originalName, - image: user.image, - custom: user.custom, - ), - ); + try { + final userToken = await _tokenManager.getToken(); + + final connectUserRequest = WsAuthMessageRequest( + products: const ['feeds'], + token: userToken.rawValue, + userDetails: ConnectUserDetailsRequest( + id: user.id, + name: user.originalName, + image: user.image, + custom: user.custom, + ), + ); - _ws.send(connectUserRequest); + _ws.send(connectUserRequest); + } catch (error) { + // Without this, a token-loading failure (e.g. the guest exchange + // failing) would leave the connection stuck in `Authenticating` + // forever, since nothing else observes this callback's Future and + // `connect()` only resolves on a `Connected`/`Disconnected` state. + // + // The default `userInitiated` source (rather than `serverInitiated`) is + // used deliberately: this is a client-side failure to obtain a token + // at all, not a retryable server condition, and `serverInitiated` is + // eligible for automatic reconnection, which would otherwise retry + // the failing token load indefinitely. + await _ws.disconnect(); + } } @override diff --git a/packages/stream_feeds/lib/src/feeds_client.dart b/packages/stream_feeds/lib/src/feeds_client.dart index b2a73054..e169b664 100644 --- a/packages/stream_feeds/lib/src/feeds_client.dart +++ b/packages/stream_feeds/lib/src/feeds_client.dart @@ -169,6 +169,7 @@ abstract interface class StreamFeedsClient { List? reconnectionPolicies, @visibleForTesting WebSocketProvider? wsProvider, @visibleForTesting api.DefaultApi? feedsRestApi, + @visibleForTesting api.DefaultApi? guestRestApi, }) = StreamFeedsClientImpl; User get user; diff --git a/packages/stream_feeds/pubspec.yaml b/packages/stream_feeds/pubspec.yaml index 126265b0..e4db9ec7 100644 --- a/packages/stream_feeds/pubspec.yaml +++ b/packages/stream_feeds/pubspec.yaml @@ -30,7 +30,8 @@ dependencies: retrofit: ^4.9.2 rxdart: ^0.28.0 state_notifier: ^1.0.0 - stream_core: ^0.4.0 + stream_core: + path: /Users/renefloor/Documents/github/stream-core-flutter/packages/stream_core uuid: ^4.5.1 dev_dependencies: diff --git a/packages/stream_feeds/test/client/feeds_client_test.dart b/packages/stream_feeds/test/client/feeds_client_test.dart index f1c877e4..beb723ce 100644 --- a/packages/stream_feeds/test/client/feeds_client_test.dart +++ b/packages/stream_feeds/test/client/feeds_client_test.dart @@ -935,4 +935,87 @@ void main() { }, ); }); + + // ============================================================ + // FEATURE: Guest User Authentication + // ============================================================ + + group('connect as guest user', () { + feedsClientTest( + 'should connect a guest user using the createGuest token flow', + user: const User.guest('guest-123'), + connect: (tester) async { + tester.mockApi( + (api) => api.createGuest( + createGuestRequest: const CreateGuestRequest( + user: UserRequest(id: 'guest-123'), + ), + ), + // The backend may reassign the id to avoid colliding with an + // existing user, so the mocked response intentionally differs + // from the requested id. + result: CreateGuestResponse( + accessToken: generateTestUserToken('guest-123-xyz').rawValue, + duration: '10ms', + user: createDefaultUserResponse( + id: 'guest-123-xyz', + role: 'guest', + ), + ), + ); + tester.mockSuccessfulAuth('guest-123-xyz'); + await tester.client.connect(); + addTearDown(tester.client.disconnect); + }, + body: (tester) { + expect( + tester.client.connectionState.value, + isA(), + ); + + // The client's exposed identity should be reconciled with the + // server-assigned guest user, not the originally-requested id. + expect(tester.client.user.id, 'guest-123-xyz'); + expect(tester.client.user.type, UserType.guest); + }, + ); + + feedsClientTest( + 'should fail to connect a guest user when the createGuest call fails', + user: const User.guest('guest-123'), + connect: (tester) { + // Wires up the WebSocket mock so the connection can open; the auth + // handshake it configures is never reached since createGuest fails + // before a WsAuthMessageRequest is ever sent. + tester.mockSuccessfulAuth('guest-123'); + tester.mockApiFailure( + (api) => api.createGuest( + createGuestRequest: const CreateGuestRequest( + user: UserRequest(id: 'guest-123'), + ), + ), + error: Exception('Failed to create guest'), + ); + }, + body: (tester) async { + final connectionStateExpectation = expectLater( + tester.client.connectionState, + emitsInOrder([ + isA(), + isA(), + isA(), + isA(), + isA(), + ]), + ); + + await expectLater( + tester.client.connect(), + throwsA(isA()), + ); + + await connectionStateExpectation; + }, + ); + }); } diff --git a/packages/stream_feeds_test/lib/src/testers/base_tester.dart b/packages/stream_feeds_test/lib/src/testers/base_tester.dart index 7a3a98d5..9bc73d10 100644 --- a/packages/stream_feeds_test/lib/src/testers/base_tester.dart +++ b/packages/stream_feeds_test/lib/src/testers/base_tester.dart @@ -270,6 +270,7 @@ void testWithTester>( generateTestUserToken(user.id), ), feedsRestApi: feedsApi, + guestRestApi: feedsApi, wsProvider: (options) => webSocketChannel, config: FeedsConfig( cdnClient: FeedsCdnClient(cdnApi),