Skip to content

Commit 1523f5d

Browse files
grdsdevclaude
andauthored
fix(types): improve JSON decoding resilience (#1301)
* fix(types): improve JSON decoding resilience with robust type validation Add explicit type casts and validation for JSON parsing across all SDK packages. Support both int and num numeric types for better compatibility with different JSON decoders. Add unknown enum values for forward compatibility with new factor types and statuses. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> * fix(session): remove sensitive data from FormatException messages Avoid leaking tokens and PII by removing json.toString() from FormatException constructors in Session.fromJson. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> * fix(gotrue): prevent double completion of refresh token completer Check if the completer is already completed before calling completeError in dispose() to avoid "Bad state: Future already completed" errors during concurrent dispose operations. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
1 parent 77ca5db commit 1523f5d

8 files changed

Lines changed: 244 additions & 80 deletions

File tree

packages/gotrue/lib/src/gotrue_client.dart

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1351,7 +1351,10 @@ class GoTrueClient {
13511351
_onAuthStateChangeControllerSync.close();
13521352
_broadcastChannel?.close();
13531353
_broadcastChannelSubscription?.cancel();
1354-
_refreshTokenCompleter?.completeError(AuthException('Disposed'));
1354+
final completer = _refreshTokenCompleter;
1355+
if (completer != null && !completer.isCompleted) {
1356+
completer.completeError(AuthException('Disposed'));
1357+
}
13551358
_autoRefreshTicker?.cancel();
13561359
}
13571360

packages/gotrue/lib/src/types/auth_response.dart

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,18 @@ class GenerateLinkResponse {
6161

6262
GenerateLinkResponse.fromJson(Map<String, dynamic> json)
6363
: properties = GenerateLinkProperties.fromJson(json),
64-
user = User.fromJson(json)!;
64+
user = _parseUser(json);
65+
66+
static User _parseUser(Map<String, dynamic> json) {
67+
final user = User.fromJson(json);
68+
if (user == null) {
69+
throw FormatException(
70+
'Failed to parse user: missing required id field',
71+
json.toString(),
72+
);
73+
}
74+
return user;
75+
}
6576
}
6677

6778
class GenerateLinkProperties {

packages/gotrue/lib/src/types/mfa.dart

Lines changed: 108 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,15 @@ class AuthMFAEnrollResponse {
2121
});
2222

2323
factory AuthMFAEnrollResponse.fromJson(Map<String, dynamic> json) {
24-
final type = FactorType.values.firstWhere((e) => e.name == json['type']);
24+
final type = FactorType.values.firstWhere(
25+
(e) => e.name == json['type'],
26+
orElse: () => FactorType.unknown,
27+
);
2528
return AuthMFAEnrollResponse(
26-
id: json['id'],
29+
id: json['id'] as String,
2730
type: type,
2831
totp: type == FactorType.totp && json['totp'] != null
29-
? TOTPEnrollment.fromJson(json['totp'])
32+
? TOTPEnrollment.fromJson(json['totp'] as Map<String, dynamic>)
3033
: null,
3134
phone: type == FactorType.phone && json['phone'] != null
3235
? PhoneEnrollment._fromJsonValue(json['phone'])
@@ -57,9 +60,9 @@ class TOTPEnrollment {
5760

5861
factory TOTPEnrollment.fromJson(Map<String, dynamic> json) {
5962
return TOTPEnrollment(
60-
qrCode: json['qr_code'],
61-
secret: json['secret'],
62-
uri: json['uri'],
63+
qrCode: json['qr_code'] as String,
64+
secret: json['secret'] as String,
65+
uri: json['uri'] as String,
6366
);
6467
}
6568
}
@@ -74,7 +77,7 @@ class PhoneEnrollment {
7477

7578
factory PhoneEnrollment.fromJson(Map<String, dynamic> json) {
7679
return PhoneEnrollment(
77-
phone: json['phone'],
80+
phone: json['phone'] as String,
7881
);
7982
}
8083

@@ -102,9 +105,17 @@ class AuthMFAChallengeResponse {
102105
const AuthMFAChallengeResponse({required this.id, required this.expiresAt});
103106

104107
factory AuthMFAChallengeResponse.fromJson(Map<String, dynamic> json) {
108+
final expiresAtValue = json['expires_at'];
109+
if (expiresAtValue is! num) {
110+
throw FormatException(
111+
'Expected expires_at to be a number, got ${expiresAtValue.runtimeType}',
112+
json.toString(),
113+
);
114+
}
115+
final expiresAt = expiresAtValue.toInt();
105116
return AuthMFAChallengeResponse(
106-
id: json['id'],
107-
expiresAt: DateTime.fromMillisecondsSinceEpoch(json['expires_at'] * 1000),
117+
id: json['id'] as String,
118+
expiresAt: DateTime.fromMillisecondsSinceEpoch(expiresAt * 1000),
108119
);
109120
}
110121
}
@@ -134,12 +145,34 @@ class AuthMFAVerifyResponse {
134145
});
135146

136147
factory AuthMFAVerifyResponse.fromJson(Map<String, dynamic> json) {
148+
final expiresInValue = json['expires_in'];
149+
if (expiresInValue is! num) {
150+
throw FormatException(
151+
'Expected expires_in to be a number, got ${expiresInValue.runtimeType}',
152+
json.toString(),
153+
);
154+
}
155+
final expiresIn = expiresInValue.toInt();
156+
final userJson = json['user'];
157+
if (userJson is! Map<String, dynamic>) {
158+
throw FormatException(
159+
'Expected user to be an object, got ${userJson.runtimeType}',
160+
json.toString(),
161+
);
162+
}
163+
final user = User.fromJson(userJson);
164+
if (user == null) {
165+
throw FormatException(
166+
'Failed to parse user object: missing required fields',
167+
json.toString(),
168+
);
169+
}
137170
return AuthMFAVerifyResponse(
138-
accessToken: json['access_token'],
139-
tokenType: json['token_type'],
140-
expiresIn: Duration(seconds: json['expires_in']),
141-
refreshToken: json['refresh_token'],
142-
user: User.fromJson(json['user'])!,
171+
accessToken: json['access_token'] as String,
172+
tokenType: json['token_type'] as String,
173+
expiresIn: Duration(seconds: expiresIn),
174+
refreshToken: json['refresh_token'] as String,
175+
user: user,
143176
);
144177
}
145178
}
@@ -151,7 +184,7 @@ class AuthMFAUnenrollResponse {
151184
const AuthMFAUnenrollResponse({required this.id});
152185

153186
factory AuthMFAUnenrollResponse.fromJson(Map<String, dynamic> json) {
154-
return AuthMFAUnenrollResponse(id: json['id']);
187+
return AuthMFAUnenrollResponse(id: json['id'] as String);
155188
}
156189
}
157190

@@ -174,9 +207,17 @@ class AuthMFAAdminListFactorsResponse {
174207
const AuthMFAAdminListFactorsResponse({required this.factors});
175208

176209
factory AuthMFAAdminListFactorsResponse.fromJson(Map<String, dynamic> json) {
210+
final factorsList = json['factors'];
211+
if (factorsList is! List) {
212+
throw FormatException(
213+
'Expected factors to be a list, got ${factorsList.runtimeType}',
214+
json.toString(),
215+
);
216+
}
177217
return AuthMFAAdminListFactorsResponse(
178-
factors:
179-
(json['factors'] as List).map((e) => Factor.fromJson(e)).toList(),
218+
factors: factorsList
219+
.map((e) => Factor.fromJson(e as Map<String, dynamic>))
220+
.toList(),
180221
);
181222
}
182223
}
@@ -188,13 +229,27 @@ class AuthMFAAdminDeleteFactorResponse {
188229
const AuthMFAAdminDeleteFactorResponse({required this.id});
189230

190231
factory AuthMFAAdminDeleteFactorResponse.fromJson(Map<String, dynamic> json) {
191-
return AuthMFAAdminDeleteFactorResponse(id: json['id']);
232+
return AuthMFAAdminDeleteFactorResponse(id: json['id'] as String);
192233
}
193234
}
194235

195-
enum FactorStatus { verified, unverified }
236+
enum FactorStatus {
237+
verified,
238+
unverified,
196239

197-
enum FactorType { totp, phone }
240+
/// Returned when the backend sends an unknown status value.
241+
/// This allows forward compatibility with new status types.
242+
unknown,
243+
}
244+
245+
enum FactorType {
246+
totp,
247+
phone,
248+
249+
/// Returned when the backend sends an unknown factor type.
250+
/// This allows forward compatibility with new factor types.
251+
unknown,
252+
}
198253

199254
class Factor {
200255
/// ID of the factor.
@@ -222,17 +277,37 @@ class Factor {
222277
});
223278

224279
factory Factor.fromJson(Map<String, dynamic> json) {
280+
DateTime parseDateTime(String key) {
281+
final value = json[key];
282+
if (value is! String) {
283+
throw FormatException(
284+
'Expected $key to be a string, got ${value.runtimeType}',
285+
json.toString(),
286+
);
287+
}
288+
try {
289+
return DateTime.parse(value);
290+
} on FormatException {
291+
throw FormatException(
292+
'Invalid date format for $key: $value',
293+
json.toString(),
294+
);
295+
}
296+
}
297+
225298
return Factor(
226-
id: json['id'],
227-
friendlyName: json['friendly_name'],
299+
id: json['id'] as String,
300+
friendlyName: json['friendly_name'] as String?,
228301
factorType: FactorType.values.firstWhere(
229302
(e) => e.name == json['factor_type'],
303+
orElse: () => FactorType.unknown,
230304
),
231305
status: FactorStatus.values.firstWhere(
232306
(e) => e.name == json['status'],
307+
orElse: () => FactorStatus.unknown,
233308
),
234-
createdAt: DateTime.parse(json['created_at']),
235-
updatedAt: DateTime.parse(json['updated_at']),
309+
createdAt: parseDateTime('created_at'),
310+
updatedAt: parseDateTime('updated_at'),
236311
);
237312
}
238313

@@ -337,12 +412,20 @@ class AMREntry {
337412
const AMREntry({required this.method, required this.timestamp});
338413

339414
factory AMREntry.fromJson(Map<String, dynamic> json) {
415+
final timestampValue = json['timestamp'];
416+
if (timestampValue is! num) {
417+
throw FormatException(
418+
'Expected timestamp to be a number, got ${timestampValue.runtimeType}',
419+
json.toString(),
420+
);
421+
}
422+
final timestamp = timestampValue.toInt();
340423
return AMREntry(
341424
method: AMRMethod.values.firstWhere(
342425
(e) => e.code == json['method'],
343426
orElse: () => AMRMethod.unknown,
344427
),
345-
timestamp: DateTime.fromMillisecondsSinceEpoch(json['timestamp'] * 1000),
428+
timestamp: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000),
346429
);
347430
}
348431
}

packages/gotrue/lib/src/types/session.dart

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,26 @@ class Session {
3131
if (json['access_token'] == null) {
3232
return null;
3333
}
34+
final userJson = json['user'];
35+
if (userJson is! Map<String, dynamic>) {
36+
throw FormatException(
37+
'Expected user to be an object, got ${userJson.runtimeType}',
38+
);
39+
}
40+
final user = User.fromJson(userJson);
41+
if (user == null) {
42+
throw FormatException(
43+
'Failed to parse user: missing required id field',
44+
);
45+
}
3446
return Session(
3547
accessToken: json['access_token'] as String,
3648
expiresIn: json['expires_in'] as int?,
3749
refreshToken: json['refresh_token'] as String?,
3850
tokenType: json['token_type'] as String,
3951
providerToken: json['provider_token'] as String?,
4052
providerRefreshToken: json['provider_refresh_token'] as String?,
41-
user: User.fromJson(json['user'] as Map<String, dynamic>)!,
53+
user: user,
4254
);
4355
}
4456

packages/postgrest/lib/src/types.dart

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,19 @@ class PostgrestResponse<T> {
5959

6060
final int count;
6161

62-
factory PostgrestResponse.fromJson(Map<String, dynamic> json) =>
63-
PostgrestResponse<T>(
64-
data: json['data'] as T,
65-
count: json['count'] as int,
62+
factory PostgrestResponse.fromJson(Map<String, dynamic> json) {
63+
final countValue = json['count'];
64+
if (countValue is! num) {
65+
throw FormatException(
66+
'Expected count to be a number, got ${countValue.runtimeType}',
67+
json.toString(),
6668
);
69+
}
70+
return PostgrestResponse<T>(
71+
data: json['data'] as T,
72+
count: countValue.toInt(),
73+
);
74+
}
6775

6876
Map<String, dynamic> toJson() => {
6977
'data': data,

packages/realtime_client/lib/src/realtime_presence.dart

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,20 @@ class Presence {
1010
/// The payload shared by users.
1111
final Map<String, dynamic> payload;
1212

13-
Presence.fromJson(Map<String, dynamic> map)
14-
: presenceRef = map['presence_ref'],
15-
payload = map..remove('presence_ref');
13+
const Presence({
14+
required this.presenceRef,
15+
required this.payload,
16+
});
17+
18+
factory Presence.fromJson(Map<String, dynamic> map) {
19+
final ref = map['presence_ref'];
20+
// Create a new map without presence_ref to avoid mutating the input
21+
final payload = Map<String, dynamic>.from(map)..remove('presence_ref');
22+
return Presence(
23+
presenceRef: ref as String? ?? '',
24+
payload: payload,
25+
);
26+
}
1627

1728
Presence deepClone() {
1829
return Presence.fromJson({

packages/realtime_client/lib/src/types.dart

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -231,15 +231,35 @@ class PostgresChangePayload {
231231
});
232232

233233
/// Creates a PostgresChangePayload instance from the enriched postgres change payload
234-
PostgresChangePayload.fromPayload(Map<String, dynamic> payload)
235-
: schema = payload['schema'],
236-
table = payload['table'],
237-
commitTimestamp =
238-
DateTime.parse(payload['commit_timestamp'] ?? '19700101'),
239-
eventType = PostgresChangeEventMethods.fromString(payload['eventType']),
240-
newRecord = Map<String, dynamic>.from(payload['new']),
241-
oldRecord = Map<String, dynamic>.from(payload['old']),
242-
errors = payload['errors'];
234+
factory PostgresChangePayload.fromPayload(Map<String, dynamic> payload) {
235+
final commitTimestampStr = payload['commit_timestamp'] as String?;
236+
DateTime commitTimestamp;
237+
try {
238+
commitTimestamp = commitTimestampStr != null
239+
? DateTime.parse(commitTimestampStr)
240+
: DateTime.fromMillisecondsSinceEpoch(0);
241+
} on FormatException {
242+
commitTimestamp = DateTime.fromMillisecondsSinceEpoch(0);
243+
}
244+
245+
final newData = payload['new'];
246+
final oldData = payload['old'];
247+
248+
return PostgresChangePayload(
249+
schema: payload['schema'] as String,
250+
table: payload['table'] as String,
251+
commitTimestamp: commitTimestamp,
252+
eventType:
253+
PostgresChangeEventMethods.fromString(payload['eventType'] as String),
254+
newRecord: newData is Map
255+
? Map<String, dynamic>.from(newData)
256+
: <String, dynamic>{},
257+
oldRecord: oldData is Map
258+
? Map<String, dynamic>.from(oldData)
259+
: <String, dynamic>{},
260+
errors: payload['errors'],
261+
);
262+
}
243263

244264
@override
245265
String toString() {

0 commit comments

Comments
 (0)