Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions docs/code_snippets/02_02_authentication.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import 'package:stream_feeds/stream_feeds.dart';

Future<void> regularUserLogin() async {
// Regular user: provide a JWT token (from your server).
final client = StreamFeedsClient(
apiKey: '<your_api_key>',
user: const User(id: 'alice'),
tokenProvider: TokenProvider.static(UserToken('<your_jwt_token>')),
);
await client.connect();
}

Future<void> dynamicTokenProvider() async {
// Dynamic token provider: fetches a new token from your server
// when the current one expires.
final client = StreamFeedsClient(
apiKey: '<your_api_key>',
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<String> fetchTokenFromYourServer(String userId) async => '<jwt>';

Future<void> 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: '<your_api_key>',
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<void> 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: '<your_api_key>',
user: const User.anonymous(),
);

// Read public feed data without connecting.
final feed = client.feed(group: 'user', id: 'alice');
await feed.getOrCreate();
}
3 changes: 2 additions & 1 deletion melos.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions packages/stream_feeds/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
150 changes: 126 additions & 24 deletions packages/stream_feeds/lib/src/client/feeds_client_impl.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -73,7 +80,8 @@ class StreamFeedsClientImpl implements StreamFeedsClient {
List<AutomaticReconnectionPolicy>? reconnectionPolicies,
WebSocketProvider? wsProvider,
api.DefaultApi? feedsRestApi,
}) {
api.DefaultApi? guestRestApi,
}) : _user = user {
// TODO: Make this configurable
const endpointConfig = EndpointConfig.production;

Expand All @@ -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,
Expand Down Expand Up @@ -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),
]),
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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();
Comment on lines +272 to +293

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


_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<void> _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
Expand Down
1 change: 1 addition & 0 deletions packages/stream_feeds/lib/src/feeds_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ abstract interface class StreamFeedsClient {
List<AutomaticReconnectionPolicy>? reconnectionPolicies,
@visibleForTesting WebSocketProvider? wsProvider,
@visibleForTesting api.DefaultApi? feedsRestApi,
@visibleForTesting api.DefaultApi? guestRestApi,
}) = StreamFeedsClientImpl;

User get user;
Expand Down
3 changes: 2 additions & 1 deletion packages/stream_feeds/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
83 changes: 83 additions & 0 deletions packages/stream_feeds/test/client/feeds_client_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<Connected>(),
);

// 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<Initialized>(),
isA<Connecting>(),
isA<Authenticating>(),
isA<Disconnecting>(),
isA<Disconnected>(),
]),
);

await expectLater(
tester.client.connect(),
throwsA(isA<ClientException>()),
);

await connectionStateExpectation;
},
);
});
}
Loading
Loading