From 7a7bce8b8426b0cc8cff9ace6b1e1cbbc33f1164 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Thu, 6 Aug 2026 14:55:49 +0200 Subject: [PATCH 1/6] feat!: use DateTime for all timestamp fields Every timestamp the SDK returns is now a DateTime in UTC, parsed once when the response is decoded, instead of an ISO 8601 String or Unix seconds int. - Session.expiresAt: int? -> DateTime? - User and UserIdentity timestamps: String/String? -> DateTime/DateTime? - OAuthClient.createdAt, updatedAt: String -> DateTime - Bucket, FileObject, FileObjectV2 and PaginatedFile timestamps: String/String? -> DateTime/DateTime? The parsing lives in shared supabase_common helpers, which also replace the per-type parsers in mfa.dart, passkey.dart and custom_oauth_provider.dart. Unix timestamps that used to be converted to local time (MFA challenge expiry, passkey challenge expiry, AMR entries) are now UTC like the rest. OAuthAuthorizationDetailsResponse.user is no longer a User: the OAuth 2.1 server only returns an id and an email there, so it is an OAuthAuthorizingUser, matching what the other client libraries expose. Also fixes User.hashCode and UserIdentity.hashCode, which hashed their metadata maps by identity while == compared them deeply, so equal instances could hash differently. --- MIGRATION.md | 68 +++++ examples/storage_transforms/lib/models.dart | 8 +- packages/gotrue/lib/src/gotrue_client.dart | 4 +- packages/gotrue/lib/src/gotrue_oauth_api.dart | 36 ++- .../lib/src/types/custom_oauth_provider.dart | 6 +- packages/gotrue/lib/src/types/mfa.dart | 43 +-- packages/gotrue/lib/src/types/passkey.dart | 28 +- packages/gotrue/lib/src/types/session.dart | 34 ++- packages/gotrue/lib/src/types/types.dart | 8 +- packages/gotrue/lib/src/types/user.dart | 91 +++---- packages/gotrue/test/client_test.dart | 10 +- packages/gotrue/test/passkey_test.dart | 4 +- .../test/src/gotrue_oauth_api_test.dart | 21 +- .../gotrue/test/src/set_session_test.dart | 5 +- packages/gotrue/test/src/types/mfa_test.dart | 2 +- .../gotrue/test/src/types/passkey_test.dart | 4 +- .../gotrue/test/src/types/session_test.dart | 45 +++- packages/gotrue/test/src/types/user_test.dart | 248 +++++++++++------- packages/realtime_client/lib/src/types.dart | 6 +- packages/storage_client/lib/src/types.dart | 40 +-- .../storage_client/lib/src/vector_types.dart | 8 +- packages/storage_client/test/basic_test.dart | 4 +- packages/storage_client/test/types_test.dart | 29 ++ .../supabase_common/lib/src/timestamp.dart | 64 +++++ .../supabase_common/lib/supabase_common.dart | 1 + .../supabase_common/test/timestamp_test.dart | 148 +++++++++++ sdk-compliance.yaml | 5 + 27 files changed, 671 insertions(+), 299 deletions(-) create mode 100644 packages/supabase_common/lib/src/timestamp.dart create mode 100644 packages/supabase_common/test/timestamp_test.dart diff --git a/MIGRATION.md b/MIGRATION.md index ac29c46ca..e2f1a7b97 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -113,6 +113,74 @@ await catalog.loadTableResult( ); ``` +### Timestamps are `DateTime` instead of `String` or `int` + +Every timestamp the SDK returns is now a `DateTime` in UTC, parsed once when the response is +decoded, instead of a raw ISO 8601 `String` or a Unix timestamp `int`. Comparing, formatting and +doing arithmetic on them no longer requires parsing them yourself. + +| Type | Fields | Before | After | +| --- | --- | --- | --- | +| `Session` | `expiresAt` | `int?` (Unix seconds) | `DateTime?` | +| `User` | `createdAt` | `String` | `DateTime` | +| `User` | `confirmationSentAt`, `recoverySentAt`, `emailChangeSentAt`, `invitedAt`, `confirmedAt`, `emailConfirmedAt`, `phoneConfirmedAt`, `lastSignInAt`, `updatedAt` | `String?` | `DateTime?` | +| `UserIdentity` | `createdAt`, `lastSignInAt`, `updatedAt` | `String?` | `DateTime?` | +| `OAuthClient` | `createdAt`, `updatedAt` | `String` | `DateTime` | +| `Bucket` | `createdAt`, `updatedAt` | `String` | `DateTime` | +| `FileObject` | `createdAt`, `updatedAt`, `lastAccessedAt` | `String?` | `DateTime?` | +| `FileObjectV2` | `createdAt` | `String` | `DateTime` | +| `FileObjectV2` | `updatedAt`, `lastAccessedAt`, `lastModified` | `String?` | `DateTime?` | +| `PaginatedFile` | `createdAt`, `updatedAt` | `String?` | `DateTime?` | + +```dart +// Before +final expiresAt = supabase.auth.currentSession?.expiresAt; +final expiry = expiresAt == null + ? null + : DateTime.fromMillisecondsSinceEpoch(expiresAt * 1000); +final createdAt = DateTime.parse(user.createdAt); + +// After +final expiry = supabase.auth.currentSession?.expiresAt; +final createdAt = user.createdAt; +``` + +If you need the previous representation, ask for it explicitly: + +```dart +final iso = user.createdAt.toIso8601String(); +final unixSeconds = session.expiresAt!.millisecondsSinceEpoch ~/ 1000; +``` + +The wire format is unchanged: `toJson()` still writes ISO 8601 strings for the `User`, `Bucket` and +`FileObject` timestamps and Unix seconds for `Session.expires_at`, so persisted sessions written by +v2 are still readable by v3. + +Two behavioural details are worth checking: + +- The `DateTime` values are in UTC. `DateTime` equality takes the time zone flag into account, so + compare against `DateTime.utc(...)` rather than `DateTime(...)`, or call `toLocal()` first. +- A timestamp the server is documented to always send is now parsed strictly. `User.createdAt` + used to fall back to an empty string when the field was missing and now throws a + `FormatException`, which surfaces a malformed payload instead of passing an unusable value on. + +### `OAuthAuthorizationDetailsResponse.user` is an `OAuthAuthorizingUser` + +The OAuth 2.1 server returns only an id and an email for the user a pending authorization request +belongs to, so `OAuthAuthorizationDetailsResponse.user` was a `User` with every other field +defaulted. It is now an `OAuthAuthorizingUser`, which carries exactly the two fields the server +sends, matching what the other Supabase client libraries expose. + +```dart +// Before +final User user = details.user; + +// After +final OAuthAuthorizingUser user = details.user; +``` + +`id` and `email` keep their names, so code that only reads those needs no change. + ### `order()` now sorts ascending by default `PostgrestTransformBuilder.order()` and `SupabaseStreamBuilder.order()` defaulted `ascending` to diff --git a/examples/storage_transforms/lib/models.dart b/examples/storage_transforms/lib/models.dart index 56f6dc453..861b2df47 100644 --- a/examples/storage_transforms/lib/models.dart +++ b/examples/storage_transforms/lib/models.dart @@ -5,12 +5,8 @@ import 'package:supabase_flutter/supabase_flutter.dart'; class StoredImage { const StoredImage({required this.path, this.createdAt}); - factory StoredImage.fromFileObject(FileObject file) => StoredImage( - path: file.name, - createdAt: file.createdAt == null - ? null - : DateTime.tryParse(file.createdAt!), - ); + factory StoredImage.fromFileObject(FileObject file) => + StoredImage(path: file.name, createdAt: file.createdAt); /// Path within the bucket, which is also the object's file name here. final String path; diff --git a/packages/gotrue/lib/src/gotrue_client.dart b/packages/gotrue/lib/src/gotrue_client.dart index 72218446a..08b0b74d4 100644 --- a/packages/gotrue/lib/src/gotrue_client.dart +++ b/packages/gotrue/lib/src/gotrue_client.dart @@ -1391,9 +1391,7 @@ class GoTrueClient { } final expiresInTicks = - (DateTime.fromMillisecondsSinceEpoch( - expiresAt * 1000, - ).difference(now).inMilliseconds / + (expiresAt.difference(now).inMilliseconds / Constants.autoRefreshTickDuration.inMilliseconds) .floor(); diff --git a/packages/gotrue/lib/src/gotrue_oauth_api.dart b/packages/gotrue/lib/src/gotrue_oauth_api.dart index 6cb279140..0324b7b65 100644 --- a/packages/gotrue/lib/src/gotrue_oauth_api.dart +++ b/packages/gotrue/lib/src/gotrue_oauth_api.dart @@ -34,6 +34,29 @@ class OAuthAuthorizedClient { } } +/// The signed-in user a pending OAuth authorization request belongs to. +/// +/// The OAuth 2.1 server only returns the identity needed to render a consent +/// screen, so this is a smaller shape than [User]. +/// +/// Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. +class OAuthAuthorizingUser { + /// Unique identifier of the user. + final String id; + + /// Email address of the user. + final String email; + + const OAuthAuthorizingUser({required this.id, required this.email}); + + factory OAuthAuthorizingUser.fromJson(Map json) { + return OAuthAuthorizingUser( + id: json['id'] as String, + email: json['email'] as String, + ); + } +} + /// An OAuth grant representing a user's authorization of an OAuth client. /// /// Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. @@ -57,7 +80,7 @@ class OAuthGrant { return OAuthGrant( client: OAuthAuthorizedClient.fromJson(json['client']), scopes: (json['scopes'] as List?)?.cast() ?? const [], - grantedAt: DateTime.parse(json['granted_at'] as String), + grantedAt: parseIso8601(json, 'granted_at'), ); } } @@ -99,8 +122,8 @@ class OAuthAuthorizationDetailsResponse extends OAuthAuthorizationResponse { /// The OAuth client requesting authorization. final OAuthAuthorizedClient client; - /// The OAuth User requesting authorization. - final User user; + /// The user the authorization request belongs to. + final OAuthAuthorizingUser user; /// The scopes requested by the OAuth client, if any. final String? scope; @@ -119,9 +142,8 @@ class OAuthAuthorizationDetailsResponse extends OAuthAuthorizationResponse { factory OAuthAuthorizationDetailsResponse.fromJson( Map json, ) { - final user = json['user'] == null ? null : User.fromJson(json['user']); - - if (user == null) { + final user = json['user']; + if (user is! Map) { throw FormatException( 'The provided JSON should contain a parseable user object', json.toString(), @@ -131,7 +153,7 @@ class OAuthAuthorizationDetailsResponse extends OAuthAuthorizationResponse { return OAuthAuthorizationDetailsResponse( authorizationId: json['authorization_id'] as String, client: OAuthAuthorizedClient.fromJson(json['client']), - user: user, + user: OAuthAuthorizingUser.fromJson(user), scope: json['scope'] as String?, redirectUri: json['redirect_uri'] as String, ); diff --git a/packages/gotrue/lib/src/types/custom_oauth_provider.dart b/packages/gotrue/lib/src/types/custom_oauth_provider.dart index 245be1827..cca82525f 100644 --- a/packages/gotrue/lib/src/types/custom_oauth_provider.dart +++ b/packages/gotrue/lib/src/types/custom_oauth_provider.dart @@ -2,6 +2,8 @@ // prefixed with `custom:`). Distinct from the OAuth 2.1 server client types // in types.dart. +import 'package:supabase_common/supabase_common.dart'; + /// Type of a custom OAuth/OIDC provider managed through the admin API. enum CustomProviderType { oauth2, @@ -208,8 +210,8 @@ class CustomOAuthProvider { : OIDCDiscoveryDocument.fromJson( discoveryDocument as Map, ), - createdAt: DateTime.parse(json['created_at'] as String), - updatedAt: DateTime.parse(json['updated_at'] as String), + createdAt: parseIso8601(json, 'created_at'), + updatedAt: parseIso8601(json, 'updated_at'), ); } } diff --git a/packages/gotrue/lib/src/types/mfa.dart b/packages/gotrue/lib/src/types/mfa.dart index f14893ff0..36579af5e 100644 --- a/packages/gotrue/lib/src/types/mfa.dart +++ b/packages/gotrue/lib/src/types/mfa.dart @@ -1,4 +1,5 @@ import 'package:gotrue/gotrue.dart'; +import 'package:supabase_common/supabase_common.dart'; class AuthMFAEnrollResponse { /// ID of the factor that was just enrolled (in an unverified state). @@ -107,17 +108,9 @@ class AuthMFAChallengeResponse { const AuthMFAChallengeResponse({required this.id, required this.expiresAt}); factory AuthMFAChallengeResponse.fromJson(Map json) { - final expiresAtValue = json['expires_at']; - if (expiresAtValue is! num) { - throw FormatException( - 'Expected expires_at to be a number, got ${expiresAtValue.runtimeType}', - json.toString(), - ); - } - final expiresAt = expiresAtValue.toInt(); return AuthMFAChallengeResponse( id: json['id'] as String, - expiresAt: DateTime.fromMillisecondsSinceEpoch(expiresAt * 1000), + expiresAt: parseUnixSeconds(json, 'expires_at'), ); } } @@ -283,24 +276,6 @@ class Factor { }); factory Factor.fromJson(Map json) { - DateTime parseDateTime(String key) { - final value = json[key]; - if (value is! String) { - throw FormatException( - 'Expected $key to be a string, got ${value.runtimeType}', - json.toString(), - ); - } - try { - return DateTime.parse(value); - } on FormatException { - throw FormatException( - 'Invalid date format for $key: $value', - json.toString(), - ); - } - } - return Factor( id: json['id'] as String, friendlyName: json['friendly_name'] as String?, @@ -312,8 +287,8 @@ class Factor { (e) => e.name == json['status'], orElse: () => FactorStatus.unknown, ), - createdAt: parseDateTime('created_at'), - updatedAt: parseDateTime('updated_at'), + createdAt: parseIso8601(json, 'created_at'), + updatedAt: parseIso8601(json, 'updated_at'), ); } @@ -431,20 +406,12 @@ class AMREntry { const AMREntry({required this.method, required this.timestamp}); factory AMREntry.fromJson(Map json) { - final timestampValue = json['timestamp']; - if (timestampValue is! num) { - throw FormatException( - 'Expected timestamp to be a number, got ${timestampValue.runtimeType}', - json.toString(), - ); - } - final timestamp = timestampValue.toInt(); return AMREntry( method: AMRMethod.values.firstWhere( (e) => e.code == json['method'], orElse: () => AMRMethod.unknown, ), - timestamp: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000), + timestamp: parseUnixSeconds(json, 'timestamp'), ); } } diff --git a/packages/gotrue/lib/src/types/passkey.dart b/packages/gotrue/lib/src/types/passkey.dart index 340bbe431..428ac96fc 100644 --- a/packages/gotrue/lib/src/types/passkey.dart +++ b/packages/gotrue/lib/src/types/passkey.dart @@ -1,4 +1,5 @@ import 'package:meta/meta.dart'; +import 'package:supabase_common/supabase_common.dart'; /// A passkey (WebAuthn credential) registered to a user. @experimental @@ -27,19 +28,11 @@ class Passkey { }); factory Passkey.fromJson(Map json) { - final createdAt = json['created_at']; - if (createdAt is! String) { - throw FormatException( - 'Expected created_at to be a string, got ${createdAt.runtimeType}', - json.toString(), - ); - } - final lastUsedAt = json['last_used_at'] as String?; return Passkey( id: json['id'] as String, friendlyName: json['friendly_name'] as String?, - createdAt: DateTime.parse(createdAt), - lastUsedAt: lastUsedAt != null ? DateTime.parse(lastUsedAt) : null, + createdAt: parseIso8601(json, 'created_at'), + lastUsedAt: tryParseIso8601(json, 'last_used_at'), ); } @@ -102,7 +95,7 @@ class PasskeyRegistrationOptionsResponse { return PasskeyRegistrationOptionsResponse( challengeId: json['challenge_id'] as String, options: Map.from(json['options'] as Map), - expiresAt: _parseExpiresAt(json), + expiresAt: parseUnixSeconds(json, 'expires_at'), ); } } @@ -137,18 +130,7 @@ class PasskeyAuthenticationOptionsResponse { return PasskeyAuthenticationOptionsResponse( challengeId: json['challenge_id'] as String, options: Map.from(json['options'] as Map), - expiresAt: _parseExpiresAt(json), + expiresAt: parseUnixSeconds(json, 'expires_at'), ); } } - -DateTime _parseExpiresAt(Map json) { - final expiresAtValue = json['expires_at']; - if (expiresAtValue is! num) { - throw FormatException( - 'Expected expires_at to be a number, got ${expiresAtValue.runtimeType}', - json.toString(), - ); - } - return DateTime.fromMillisecondsSinceEpoch(expiresAtValue.toInt() * 1000); -} diff --git a/packages/gotrue/lib/src/types/session.dart b/packages/gotrue/lib/src/types/session.dart index 214479227..1678ae2cd 100644 --- a/packages/gotrue/lib/src/types/session.dart +++ b/packages/gotrue/lib/src/types/session.dart @@ -2,6 +2,7 @@ import 'package:gotrue/src/constants.dart'; import 'package:gotrue/src/helper.dart'; import 'package:gotrue/src/types/user.dart'; import 'package:meta/meta.dart'; +import 'package:supabase_common/supabase_common.dart'; class Session { final String? providerToken; @@ -56,10 +57,13 @@ class Session { } Map toJson() { + final expiresAt = this.expiresAt; return { 'access_token': accessToken, 'expires_in': expiresIn, - 'expires_at': expiresAt, + 'expires_at': expiresAt == null + ? null + : unixSecondsFromDateTime(expiresAt), 'refresh_token': refreshToken, 'token_type': tokenType, 'provider_token': providerToken, @@ -68,19 +72,17 @@ class Session { }; } - /// The Unix timestamp, in **seconds**, of when the token will expire. + /// The point in time, in UTC, when [accessToken] expires. /// /// Derived from the `exp` claim of [accessToken], not read from the login - /// response's JSON body. - /// - /// To convert this to a [DateTime], multiply by 1000 since - /// [DateTime.fromMillisecondsSinceEpoch] expects milliseconds: - /// `DateTime.fromMillisecondsSinceEpoch(expiresAt * 1000)`. - late int? expiresAt = _expiresAt; + /// response's JSON body. `null` when [accessToken] carries no expiry or + /// cannot be decoded. + late DateTime? expiresAt = _expiresAt; - int? get _expiresAt { + DateTime? get _expiresAt { try { - return decodeJwtPayload(accessToken).exp; + final exp = decodeJwtPayload(accessToken).exp; + return exp == null ? null : dateTimeFromUnixSeconds(exp); } catch (_) { return null; } @@ -91,22 +93,18 @@ class Session { /// /// The 30 second buffer is to account for latency issues. bool get isExpired { + final expiresAt = this.expiresAt; if (expiresAt == null) return false; - return DateTime.now() - .add(Constants.expiryMargin) - .isAfter( - DateTime.fromMillisecondsSinceEpoch(expiresAt! * 1000), - ); + return DateTime.now().add(Constants.expiryMargin).isAfter(expiresAt); } /// Returns `true` if the token is expired right now, without applying the /// [Constants.expiryMargin] buffer used by [isExpired]. @internal bool get isExpiredWithoutMargin { + final expiresAt = this.expiresAt; if (expiresAt == null) return false; - return DateTime.now().isAfter( - DateTime.fromMillisecondsSinceEpoch(expiresAt! * 1000), - ); + return DateTime.now().isAfter(expiresAt); } Session copyWith({ diff --git a/packages/gotrue/lib/src/types/types.dart b/packages/gotrue/lib/src/types/types.dart index 1bc8ef460..9deb7b95b 100644 --- a/packages/gotrue/lib/src/types/types.dart +++ b/packages/gotrue/lib/src/types/types.dart @@ -165,10 +165,10 @@ class OAuthClient { final String? scope; /// Timestamp when the client was created - final String createdAt; + final DateTime createdAt; /// Timestamp when the client was last updated - final String updatedAt; + final DateTime updatedAt; const OAuthClient({ required this.clientId, @@ -213,8 +213,8 @@ class OAuthClient { ) .toList(), scope: json['scope'] as String?, - createdAt: json['created_at'] as String, - updatedAt: json['updated_at'] as String, + createdAt: parseIso8601(json, 'created_at'), + updatedAt: parseIso8601(json, 'updated_at'), ); } } diff --git a/packages/gotrue/lib/src/types/user.dart b/packages/gotrue/lib/src/types/user.dart index e3d6a3afa..6f6f2eaeb 100644 --- a/packages/gotrue/lib/src/types/user.dart +++ b/packages/gotrue/lib/src/types/user.dart @@ -1,25 +1,26 @@ import 'package:collection/collection.dart'; import 'package:gotrue/src/types/mfa.dart'; +import 'package:supabase_common/supabase_common.dart'; class User { final String id; final Map appMetadata; final Map? userMetadata; final String aud; - final String? confirmationSentAt; - final String? recoverySentAt; - final String? emailChangeSentAt; + final DateTime? confirmationSentAt; + final DateTime? recoverySentAt; + final DateTime? emailChangeSentAt; final String? newEmail; - final String? invitedAt; + final DateTime? invitedAt; final String? actionLink; final String? email; final String? phone; - final String createdAt; - final String? emailConfirmedAt; - final String? phoneConfirmedAt; - final String? lastSignInAt; + final DateTime createdAt; + final DateTime? emailConfirmedAt; + final DateTime? phoneConfirmedAt; + final DateTime? lastSignInAt; final String? role; - final String? updatedAt; + final DateTime? updatedAt; final List? identities; final List? factors; final bool isAnonymous; @@ -60,20 +61,20 @@ class User { appMetadata: json['app_metadata'] as Map? ?? {}, userMetadata: json['user_metadata'] as Map?, aud: json['aud'] ?? '', - confirmationSentAt: json['confirmation_sent_at'], - recoverySentAt: json['recovery_sent_at'], - emailChangeSentAt: json['email_change_sent_at'], + confirmationSentAt: tryParseIso8601(json, 'confirmation_sent_at'), + recoverySentAt: tryParseIso8601(json, 'recovery_sent_at'), + emailChangeSentAt: tryParseIso8601(json, 'email_change_sent_at'), newEmail: json['new_email'], - invitedAt: json['invited_at'], + invitedAt: tryParseIso8601(json, 'invited_at'), actionLink: json['action_link'], email: json['email'], phone: json['phone'], - createdAt: json['created_at'] ?? '', - emailConfirmedAt: json['email_confirmed_at'], - phoneConfirmedAt: json['phone_confirmed_at'], - lastSignInAt: json['last_sign_in_at'], + createdAt: parseIso8601(json, 'created_at'), + emailConfirmedAt: tryParseIso8601(json, 'email_confirmed_at'), + phoneConfirmedAt: tryParseIso8601(json, 'phone_confirmed_at'), + lastSignInAt: tryParseIso8601(json, 'last_sign_in_at'), role: json['role'], - updatedAt: json['updated_at'], + updatedAt: tryParseIso8601(json, 'updated_at'), identities: json['identities'] != null ? List.from( json['identities']?.map((x) => UserIdentity.fromMap(x)), @@ -92,20 +93,20 @@ class User { 'app_metadata': appMetadata, 'user_metadata': userMetadata, 'aud': aud, - 'confirmation_sent_at': confirmationSentAt, - 'recovery_sent_at': recoverySentAt, - 'email_change_sent_at': emailChangeSentAt, + 'confirmation_sent_at': confirmationSentAt?.toIso8601String(), + 'recovery_sent_at': recoverySentAt?.toIso8601String(), + 'email_change_sent_at': emailChangeSentAt?.toIso8601String(), 'new_email': newEmail, - 'invited_at': invitedAt, + 'invited_at': invitedAt?.toIso8601String(), 'action_link': actionLink, 'email': email, 'phone': phone, - 'created_at': createdAt, - 'email_confirmed_at': emailConfirmedAt, - 'phone_confirmed_at': phoneConfirmedAt, - 'last_sign_in_at': lastSignInAt, + 'created_at': createdAt.toIso8601String(), + 'email_confirmed_at': emailConfirmedAt?.toIso8601String(), + 'phone_confirmed_at': phoneConfirmedAt?.toIso8601String(), + 'last_sign_in_at': lastSignInAt?.toIso8601String(), 'role': role, - 'updated_at': updatedAt, + 'updated_at': updatedAt?.toIso8601String(), 'identities': identities?.map((identity) => identity.toJson()).toList(), 'factors': factors?.map((factor) => factor.toJson()).toList(), 'is_anonymous': isAnonymous, @@ -156,9 +157,11 @@ class User { @override int get hashCode { + final collectionHash = const DeepCollectionEquality().hash; + return id.hashCode ^ - appMetadata.hashCode ^ - userMetadata.hashCode ^ + collectionHash(appMetadata) ^ + collectionHash(userMetadata) ^ aud.hashCode ^ confirmationSentAt.hashCode ^ recoverySentAt.hashCode ^ @@ -174,8 +177,8 @@ class User { lastSignInAt.hashCode ^ role.hashCode ^ updatedAt.hashCode ^ - identities.hashCode ^ - factors.hashCode ^ + collectionHash(identities) ^ + collectionHash(factors) ^ isAnonymous.hashCode; } } @@ -186,9 +189,9 @@ class UserIdentity { final Map? identityData; final String identityId; final String provider; - final String? createdAt; - final String? lastSignInAt; - final String? updatedAt; + final DateTime? createdAt; + final DateTime? lastSignInAt; + final DateTime? updatedAt; const UserIdentity({ required this.id, @@ -207,9 +210,9 @@ class UserIdentity { Map? identityData, String? identityId, String? provider, - String? createdAt, - String? lastSignInAt, - String? updatedAt, + DateTime? createdAt, + DateTime? lastSignInAt, + DateTime? updatedAt, }) { return UserIdentity( id: id ?? this.id, @@ -230,9 +233,9 @@ class UserIdentity { identityData: (map['identity_data'] as Map?)?.cast(), identityId: (map['identity_id'] ?? '') as String, provider: map['provider'] as String, - createdAt: map['created_at'] as String?, - lastSignInAt: map['last_sign_in_at'] as String?, - updatedAt: map['updated_at'] as String?, + createdAt: tryParseIso8601(map, 'created_at'), + lastSignInAt: tryParseIso8601(map, 'last_sign_in_at'), + updatedAt: tryParseIso8601(map, 'updated_at'), ); } @@ -243,9 +246,9 @@ class UserIdentity { 'identity_data': identityData, 'identity_id': identityId, 'provider': provider, - 'created_at': createdAt, - 'last_sign_in_at': lastSignInAt, - 'updated_at': updatedAt, + 'created_at': createdAt?.toIso8601String(), + 'last_sign_in_at': lastSignInAt?.toIso8601String(), + 'updated_at': updatedAt?.toIso8601String(), }; } @@ -277,7 +280,7 @@ class UserIdentity { int get hashCode { return id.hashCode ^ userId.hashCode ^ - identityData.hashCode ^ + const DeepCollectionEquality().hash(identityData) ^ identityId.hashCode ^ provider.hashCode ^ createdAt.hashCode ^ diff --git a/packages/gotrue/test/client_test.dart b/packages/gotrue/test/client_test.dart index dd555bb28..5daac4a87 100644 --- a/packages/gotrue/test/client_test.dart +++ b/packages/gotrue/test/client_test.dart @@ -245,7 +245,10 @@ void main() { expect(data?.user.id, isA()); final payload = decodeJwt(data!.accessToken).payload; - expect(payload.exp, data.expiresAt); + expect( + data.expiresAt, + DateTime.fromMillisecondsSinceEpoch(payload.exp! * 1000, isUtc: true), + ); }); test('Get user', () async { @@ -269,7 +272,10 @@ void main() { expect(data?.user.id, isA()); final payload = decodeJwt(data!.accessToken).payload; - expect(payload.exp, data.expiresAt); + expect( + data.expiresAt, + DateTime.fromMillisecondsSinceEpoch(payload.exp! * 1000, isUtc: true), + ); }); test('Set session', () async { diff --git a/packages/gotrue/test/passkey_test.dart b/packages/gotrue/test/passkey_test.dart index 420d7f681..21af80765 100644 --- a/packages/gotrue/test/passkey_test.dart +++ b/packages/gotrue/test/passkey_test.dart @@ -57,7 +57,7 @@ void main() { expect(response.options['challenge'], isNotEmpty); expect( response.expiresAt, - DateTime.fromMillisecondsSinceEpoch(1735689900 * 1000), + DateTime.fromMillisecondsSinceEpoch(1735689900 * 1000, isUtc: true), ); }); @@ -114,7 +114,7 @@ void main() { expect(response.options['user']['name'], 'user@example.com'); expect( response.expiresAt, - DateTime.fromMillisecondsSinceEpoch(1735689900 * 1000), + DateTime.fromMillisecondsSinceEpoch(1735689900 * 1000, isUtc: true), ); }); diff --git a/packages/gotrue/test/src/gotrue_oauth_api_test.dart b/packages/gotrue/test/src/gotrue_oauth_api_test.dart index 8ab8135fc..dd4fc2f88 100644 --- a/packages/gotrue/test/src/gotrue_oauth_api_test.dart +++ b/packages/gotrue/test/src/gotrue_oauth_api_test.dart @@ -44,11 +44,30 @@ void main() { equals('7263e727-435b-4d38-a5ff-a14c954b8680'), ); expect(actual.client.clientName, equals('OAuth test client')); + expect(actual.user, isA()); expect(actual.user.id, equals('1bee2038-51fe-4f93-8fbb-442df18657ff')); expect(actual.user.email, equals('translator.user@mail.com')); }); - test('throws ArgumentError when user information is missing', () { + test('throws FormatException when the user is not an object', () { + final json = { + 'authorization_id': '6abuj667j4nmdotzu3w2ro5r33xezvae', + 'redirect_uri': 'http://localhost:50200/onboarding/auth/consent', + 'client': { + 'id': '7263e727-435b-4d38-a5ff-a14c954b8680', + 'name': 'OAuth test client', + }, + 'user': 'translator.user@mail.com', + 'scope': 'email', + }; + + expect( + () => OAuthAuthorizationDetailsResponse.fromJson(json), + throwsFormatException, + ); + }); + + test('throws FormatException when user information is missing', () { final json = { 'authorization_id': '6abuj667j4nmdotzu3w2ro5r33xezvae', 'redirect_uri': 'http://localhost:50200/onboarding/auth/consent', diff --git a/packages/gotrue/test/src/set_session_test.dart b/packages/gotrue/test/src/set_session_test.dart index c4ffc8441..7457f8b28 100644 --- a/packages/gotrue/test/src/set_session_test.dart +++ b/packages/gotrue/test/src/set_session_test.dart @@ -204,7 +204,10 @@ void main() { ); // expiresAt is re-derived from the JWT's own exp, not from expiresIn. - expect(response.session?.expiresAt, equals(exp)); + expect( + response.session?.expiresAt, + equals(DateTime.fromMillisecondsSinceEpoch(exp * 1000, isUtc: true)), + ); }); test( diff --git a/packages/gotrue/test/src/types/mfa_test.dart b/packages/gotrue/test/src/types/mfa_test.dart index 4118036f3..f0f658701 100644 --- a/packages/gotrue/test/src/types/mfa_test.dart +++ b/packages/gotrue/test/src/types/mfa_test.dart @@ -38,7 +38,7 @@ void main() { expect(entry.method, AMRMethod.passkey); expect( entry.timestamp, - DateTime.fromMillisecondsSinceEpoch(1735689600 * 1000), + DateTime.fromMillisecondsSinceEpoch(1735689600 * 1000, isUtc: true), ); }); diff --git a/packages/gotrue/test/src/types/passkey_test.dart b/packages/gotrue/test/src/types/passkey_test.dart index 1b05c6b3a..45c353830 100644 --- a/packages/gotrue/test/src/types/passkey_test.dart +++ b/packages/gotrue/test/src/types/passkey_test.dart @@ -75,7 +75,7 @@ void main() { expect(response.options['rp'], {'id': 'example.com'}); expect( response.expiresAt, - DateTime.fromMillisecondsSinceEpoch(1735689900 * 1000), + DateTime.fromMillisecondsSinceEpoch(1735689900 * 1000, isUtc: true), ); }); @@ -106,7 +106,7 @@ void main() { expect(response.options['rpId'], 'example.com'); expect( response.expiresAt, - DateTime.fromMillisecondsSinceEpoch(1735689900 * 1000), + DateTime.fromMillisecondsSinceEpoch(1735689900 * 1000, isUtc: true), ); }); diff --git a/packages/gotrue/test/src/types/session_test.dart b/packages/gotrue/test/src/types/session_test.dart index d18d0eb53..23217a61a 100644 --- a/packages/gotrue/test/src/types/session_test.dart +++ b/packages/gotrue/test/src/types/session_test.dart @@ -10,12 +10,12 @@ void main() { late User mockUser; setUp(() { - mockUser = const User( + mockUser = User( id: '123', appMetadata: {}, userMetadata: {}, aud: 'authenticated', - createdAt: '2023-01-01T00:00:00Z', + createdAt: DateTime.utc(2023, 1, 1), ); }); @@ -144,7 +144,20 @@ void main() { expect(json['expires_at'], isNotNull); }); - test('includes computed expiresAt field', () { + test('serializes expiresAt as Unix seconds', () { + final exp = 1700000000; + final header = base64Encode(utf8.encode('{"alg":"HS256","typ":"JWT"}')); + final payload = base64Encode(utf8.encode('{"exp":$exp}')); + final session = Session( + accessToken: '$header.$payload.signature', + tokenType: 'bearer', + user: mockUser, + ); + + expect(session.toJson()['expires_at'], equals(exp)); + }); + + test('serializes expires_at as null when the JWT has no expiry', () { final session = Session( accessToken: 'test-access-token', tokenType: 'bearer', @@ -154,7 +167,7 @@ void main() { final json = session.toJson(); expect(json, contains('expires_at')); - expect(json['expires_at'], equals(session.expiresAt)); + expect(json['expires_at'], isNull); }); }); @@ -169,7 +182,7 @@ void main() { expect(session.expiresAt, isNull); }); - test('returns exp claim from valid JWT', () { + test('returns the exp claim of the JWT as a UTC DateTime', () { final now = DateTime.now(); final exp = (now.millisecondsSinceEpoch / 1000).floor() + 3600; final header = base64Encode(utf8.encode('{"alg":"HS256","typ":"JWT"}')); @@ -182,7 +195,11 @@ void main() { user: mockUser, ); - expect(session.expiresAt, equals(exp)); + expect( + session.expiresAt, + equals(DateTime.fromMillisecondsSinceEpoch(exp * 1000, isUtc: true)), + ); + expect(session.expiresAt!.isUtc, isTrue); }); test('handles malformed JWT gracefully', () { @@ -294,12 +311,12 @@ void main() { user: mockUser, ); - final newUser = const User( + final newUser = User( id: '456', appMetadata: {}, userMetadata: {}, aud: 'authenticated', - createdAt: '2023-01-02T00:00:00Z', + createdAt: DateTime.utc(2023, 1, 2), ); final copy = original.copyWith(user: newUser); @@ -319,12 +336,12 @@ void main() { user: mockUser, ); - final newUser = const User( + final newUser = User( id: '456', appMetadata: {}, userMetadata: {}, aud: 'authenticated', - createdAt: '2023-01-02T00:00:00Z', + createdAt: DateTime.utc(2023, 1, 2), ); final copy = original.copyWith( @@ -441,20 +458,20 @@ void main() { }); test('returns false for sessions with different users', () { - final user1 = const User( + final user1 = User( id: '123', appMetadata: {}, userMetadata: {}, aud: 'authenticated', - createdAt: '2023-01-01T00:00:00Z', + createdAt: DateTime.utc(2023, 1, 1), ); - final user2 = const User( + final user2 = User( id: '456', appMetadata: {}, userMetadata: {}, aud: 'authenticated', - createdAt: '2023-01-01T00:00:00Z', + createdAt: DateTime.utc(2023, 1, 1), ); final session1 = Session( diff --git a/packages/gotrue/test/src/types/user_test.dart b/packages/gotrue/test/src/types/user_test.dart index 3261e31a1..7f213b2c6 100644 --- a/packages/gotrue/test/src/types/user_test.dart +++ b/packages/gotrue/test/src/types/user_test.dart @@ -47,7 +47,7 @@ void main() { expect(user.appMetadata, equals({})); expect(user.userMetadata, equals({})); expect(user.aud, equals('authenticated')); - expect(user.createdAt, equals('2023-01-01T00:00:00Z')); + expect(user.createdAt, equals(DateTime.utc(2023, 1, 1))); expect(user.isAnonymous, isFalse); }); @@ -87,20 +87,20 @@ void main() { equals({'name': 'John Doe'}), ); expect(user.aud, equals('authenticated')); - expect(user.confirmationSentAt, equals('2023-01-01T00:00:00Z')); - expect(user.recoverySentAt, equals('2023-01-01T01:00:00Z')); - expect(user.emailChangeSentAt, equals('2023-01-01T02:00:00Z')); + expect(user.confirmationSentAt, equals(DateTime.utc(2023, 1, 1))); + expect(user.recoverySentAt, equals(DateTime.utc(2023, 1, 1, 1))); + expect(user.emailChangeSentAt, equals(DateTime.utc(2023, 1, 1, 2))); expect(user.newEmail, equals('new@example.com')); - expect(user.invitedAt, equals('2023-01-01T03:00:00Z')); + expect(user.invitedAt, equals(DateTime.utc(2023, 1, 1, 3))); expect(user.actionLink, equals('https://example.com/action')); expect(user.email, equals('test@example.com')); expect(user.phone, equals('+1234567890')); - expect(user.createdAt, equals('2023-01-01T00:00:00Z')); - expect(user.emailConfirmedAt, equals('2023-01-01T05:00:00Z')); - expect(user.phoneConfirmedAt, equals('2023-01-01T06:00:00Z')); - expect(user.lastSignInAt, equals('2023-01-01T07:00:00Z')); + expect(user.createdAt, equals(DateTime.utc(2023, 1, 1))); + expect(user.emailConfirmedAt, equals(DateTime.utc(2023, 1, 1, 5))); + expect(user.phoneConfirmedAt, equals(DateTime.utc(2023, 1, 1, 6))); + expect(user.lastSignInAt, equals(DateTime.utc(2023, 1, 1, 7))); expect(user.role, equals('authenticated')); - expect(user.updatedAt, equals('2023-01-01T08:00:00Z')); + expect(user.updatedAt, equals(DateTime.utc(2023, 1, 1, 8))); expect(user.isAnonymous, isTrue); }); @@ -148,7 +148,7 @@ void main() { expect(user!.appMetadata, equals({})); }); - test('handles empty string defaults for id, aud, and createdAt', () { + test('returns null when id is null, before parsing any timestamp', () { final json = { 'id': null, 'app_metadata': {}, @@ -162,6 +162,44 @@ void main() { expect(user, isNull); }); + test('throws when created_at is missing', () { + final json = { + 'id': '123', + 'app_metadata': {}, + 'user_metadata': {}, + 'aud': 'authenticated', + }; + + expect(() => User.fromJson(json), throwsFormatException); + }); + + test('throws when created_at is not a valid timestamp', () { + final json = { + 'id': '123', + 'app_metadata': {}, + 'user_metadata': {}, + 'aud': 'authenticated', + 'created_at': 'not a timestamp', + }; + + expect(() => User.fromJson(json), throwsFormatException); + }); + + test('normalizes timestamps with an offset to UTC', () { + final json = { + 'id': '123', + 'app_metadata': {}, + 'user_metadata': {}, + 'aud': 'authenticated', + 'created_at': '2023-01-01T02:00:00+02:00', + }; + + final user = User.fromJson(json); + + expect(user!.createdAt, equals(DateTime.utc(2023, 1, 1))); + expect(user.createdAt.isUtc, isTrue); + }); + test('creates user with identities', () { final json = { 'id': '123', @@ -255,25 +293,25 @@ void main() { group('toJson', () { test('serializes user correctly', () { - const user = User( + final user = User( id: '123', appMetadata: {'provider': 'email'}, userMetadata: {'name': 'John Doe'}, aud: 'authenticated', - confirmationSentAt: '2023-01-01T00:00:00Z', - recoverySentAt: '2023-01-01T01:00:00Z', - emailChangeSentAt: '2023-01-01T02:00:00Z', + confirmationSentAt: DateTime.utc(2023, 1, 1), + recoverySentAt: DateTime.utc(2023, 1, 1, 1), + emailChangeSentAt: DateTime.utc(2023, 1, 1, 2), newEmail: 'new@example.com', - invitedAt: '2023-01-01T03:00:00Z', + invitedAt: DateTime.utc(2023, 1, 1, 3), actionLink: 'https://example.com/action', email: 'test@example.com', phone: '+1234567890', - createdAt: '2023-01-01T00:00:00Z', - emailConfirmedAt: '2023-01-01T05:00:00Z', - phoneConfirmedAt: '2023-01-01T06:00:00Z', - lastSignInAt: '2023-01-01T07:00:00Z', + createdAt: DateTime.utc(2023, 1, 1), + emailConfirmedAt: DateTime.utc(2023, 1, 1, 5), + phoneConfirmedAt: DateTime.utc(2023, 1, 1, 6), + lastSignInAt: DateTime.utc(2023, 1, 1, 7), role: 'authenticated', - updatedAt: '2023-01-01T08:00:00Z', + updatedAt: DateTime.utc(2023, 1, 1, 8), isAnonymous: true, ); @@ -283,40 +321,46 @@ void main() { expect(json['app_metadata'], equals({'provider': 'email'})); expect(json['user_metadata'], equals({'name': 'John Doe'})); expect(json['aud'], equals('authenticated')); - expect(json['confirmation_sent_at'], equals('2023-01-01T00:00:00Z')); - expect(json['recovery_sent_at'], equals('2023-01-01T01:00:00Z')); - expect(json['email_change_sent_at'], equals('2023-01-01T02:00:00Z')); + expect( + json['confirmation_sent_at'], + equals('2023-01-01T00:00:00.000Z'), + ); + expect(json['recovery_sent_at'], equals('2023-01-01T01:00:00.000Z')); + expect( + json['email_change_sent_at'], + equals('2023-01-01T02:00:00.000Z'), + ); expect(json['new_email'], equals('new@example.com')); - expect(json['invited_at'], equals('2023-01-01T03:00:00Z')); + expect(json['invited_at'], equals('2023-01-01T03:00:00.000Z')); expect(json['action_link'], equals('https://example.com/action')); expect(json['email'], equals('test@example.com')); expect(json['phone'], equals('+1234567890')); - expect(json['created_at'], equals('2023-01-01T00:00:00Z')); - expect(json['email_confirmed_at'], equals('2023-01-01T05:00:00Z')); - expect(json['phone_confirmed_at'], equals('2023-01-01T06:00:00Z')); - expect(json['last_sign_in_at'], equals('2023-01-01T07:00:00Z')); + expect(json['created_at'], equals('2023-01-01T00:00:00.000Z')); + expect(json['email_confirmed_at'], equals('2023-01-01T05:00:00.000Z')); + expect(json['phone_confirmed_at'], equals('2023-01-01T06:00:00.000Z')); + expect(json['last_sign_in_at'], equals('2023-01-01T07:00:00.000Z')); expect(json['role'], equals('authenticated')); - expect(json['updated_at'], equals('2023-01-01T08:00:00Z')); + expect(json['updated_at'], equals('2023-01-01T08:00:00.000Z')); expect(json['is_anonymous'], equals(true)); }); test('serializes identities correctly', () { - const identity = UserIdentity( + final identity = UserIdentity( id: 'identity-1', userId: '123', identityData: {'email': 'test@example.com'}, identityId: 'identity-1', provider: 'email', - createdAt: '2023-01-01T00:00:00Z', - lastSignInAt: '2023-01-01T00:00:00Z', + createdAt: DateTime.utc(2023, 1, 1), + lastSignInAt: DateTime.utc(2023, 1, 1), ); - const user = User( + final user = User( id: '123', appMetadata: {}, userMetadata: {}, aud: 'authenticated', - createdAt: '2023-01-01T00:00:00Z', + createdAt: DateTime.utc(2023, 1, 1), identities: [identity], ); @@ -328,12 +372,12 @@ void main() { }); test('handles null identities and factors', () { - const user = User( + final user = User( id: '123', appMetadata: {}, userMetadata: {}, aud: 'authenticated', - createdAt: '2023-01-01T00:00:00Z', + createdAt: DateTime.utc(2023, 1, 1), identities: null, factors: null, ); @@ -347,13 +391,13 @@ void main() { group('toString', () { test('includes all user properties', () { - const user = User( + final user = User( id: '123', appMetadata: {'provider': 'email'}, userMetadata: {'name': 'John Doe'}, aud: 'authenticated', email: 'test@example.com', - createdAt: '2023-01-01T00:00:00Z', + createdAt: DateTime.utc(2023, 1, 1), isAnonymous: true, ); @@ -368,23 +412,23 @@ void main() { group('equality and hashCode', () { test('returns true for identical users', () { - const user1 = User( + final user1 = User( id: '123', appMetadata: {'provider': 'email'}, userMetadata: {'name': 'John Doe'}, aud: 'authenticated', email: 'test@example.com', - createdAt: '2023-01-01T00:00:00Z', + createdAt: DateTime.utc(2023, 1, 1), isAnonymous: false, ); - const user2 = User( + final user2 = User( id: '123', appMetadata: {'provider': 'email'}, userMetadata: {'name': 'John Doe'}, aud: 'authenticated', email: 'test@example.com', - createdAt: '2023-01-01T00:00:00Z', + createdAt: DateTime.utc(2023, 1, 1), isAnonymous: false, ); @@ -393,47 +437,47 @@ void main() { }); test('returns false for users with different ids', () { - const user1 = User( + final user1 = User( id: '123', appMetadata: {}, userMetadata: {}, aud: 'authenticated', - createdAt: '2023-01-01T00:00:00Z', + createdAt: DateTime.utc(2023, 1, 1), ); - const user2 = User( + final user2 = User( id: '456', appMetadata: {}, userMetadata: {}, aud: 'authenticated', - createdAt: '2023-01-01T00:00:00Z', + createdAt: DateTime.utc(2023, 1, 1), ); expect(user1, isNot(equals(user2))); }); test('returns false for users with different metadata', () { - const user1 = User( + final user1 = User( id: '123', appMetadata: {'provider': 'email'}, userMetadata: {}, aud: 'authenticated', - createdAt: '2023-01-01T00:00:00Z', + createdAt: DateTime.utc(2023, 1, 1), ); - const user2 = User( + final user2 = User( id: '123', appMetadata: {'provider': 'oauth'}, userMetadata: {}, aud: 'authenticated', - createdAt: '2023-01-01T00:00:00Z', + createdAt: DateTime.utc(2023, 1, 1), ); expect(user1, isNot(equals(user2))); }); test('handles deep collection equality correctly', () { - const user1 = User( + final user1 = User( id: '123', appMetadata: { 'nested': {'key': 'value'}, @@ -442,10 +486,10 @@ void main() { 'list': [1, 2, 3], }, aud: 'authenticated', - createdAt: '2023-01-01T00:00:00Z', + createdAt: DateTime.utc(2023, 1, 1), ); - const user2 = User( + final user2 = User( id: '123', appMetadata: { 'nested': {'key': 'value'}, @@ -454,7 +498,7 @@ void main() { 'list': [1, 2, 3], }, aud: 'authenticated', - createdAt: '2023-01-01T00:00:00Z', + createdAt: DateTime.utc(2023, 1, 1), ); expect(user1, equals(user2)); @@ -463,14 +507,14 @@ void main() { group('roundtrip serialization', () { test('preserves all data through JSON roundtrip', () { - const original = User( + final original = User( id: '123', appMetadata: {'provider': 'email'}, userMetadata: {'name': 'John Doe'}, aud: 'authenticated', email: 'test@example.com', phone: '+1234567890', - createdAt: '2023-01-01T00:00:00Z', + createdAt: DateTime.utc(2023, 1, 1), isAnonymous: true, ); @@ -481,7 +525,7 @@ void main() { }); test('preserves complex nested data', () { - const original = User( + final original = User( id: '123', appMetadata: { 'provider': 'oauth', @@ -493,7 +537,7 @@ void main() { 'preferences': ['dark_mode', 'notifications'], }, aud: 'authenticated', - createdAt: '2023-01-01T00:00:00Z', + createdAt: DateTime.utc(2023, 1, 1), ); final json = original.toJson(); @@ -528,9 +572,9 @@ void main() { ); expect(identity.identityId, equals('identity-1')); expect(identity.provider, equals('email')); - expect(identity.createdAt, equals('2023-01-01T00:00:00Z')); - expect(identity.lastSignInAt, equals('2023-01-01T00:00:00Z')); - expect(identity.updatedAt, equals('2023-01-01T08:00:00Z')); + expect(identity.createdAt, equals(DateTime.utc(2023, 1, 1))); + expect(identity.lastSignInAt, equals(DateTime.utc(2023, 1, 1))); + expect(identity.updatedAt, equals(DateTime.utc(2023, 1, 1, 8))); }); test('handles missing identity_id with empty string default', () { @@ -583,15 +627,15 @@ void main() { group('toJson', () { test('serializes identity correctly', () { - const identity = UserIdentity( + final identity = UserIdentity( id: 'identity-1', userId: '123', identityData: {'email': 'test@example.com'}, identityId: 'identity-1', provider: 'email', - createdAt: '2023-01-01T00:00:00Z', - lastSignInAt: '2023-01-01T00:00:00Z', - updatedAt: '2023-01-01T08:00:00Z', + createdAt: DateTime.utc(2023, 1, 1), + lastSignInAt: DateTime.utc(2023, 1, 1), + updatedAt: DateTime.utc(2023, 1, 1, 8), ); final json = identity.toJson(); @@ -604,27 +648,27 @@ void main() { ); expect(json['identity_id'], equals('identity-1')); expect(json['provider'], equals('email')); - expect(json['created_at'], equals('2023-01-01T00:00:00Z')); - expect(json['last_sign_in_at'], equals('2023-01-01T00:00:00Z')); - expect(json['updated_at'], equals('2023-01-01T08:00:00Z')); + expect(json['created_at'], equals('2023-01-01T00:00:00.000Z')); + expect(json['last_sign_in_at'], equals('2023-01-01T00:00:00.000Z')); + expect(json['updated_at'], equals('2023-01-01T08:00:00.000Z')); }); }); group('copyWith', () { test('creates copy with updated fields', () { - const original = UserIdentity( + final original = UserIdentity( id: 'identity-1', userId: '123', identityData: {'email': 'old@example.com'}, identityId: 'identity-1', provider: 'email', - createdAt: '2023-01-01T00:00:00Z', - lastSignInAt: '2023-01-01T00:00:00Z', + createdAt: DateTime.utc(2023, 1, 1), + lastSignInAt: DateTime.utc(2023, 1, 1), ); final copy = original.copyWith( identityData: {'email': 'new@example.com'}, - lastSignInAt: '2023-01-02T00:00:00Z', + lastSignInAt: DateTime.utc(2023, 1, 2), ); expect(copy.id, equals(original.id)); @@ -636,18 +680,18 @@ void main() { expect(copy.identityId, equals(original.identityId)); expect(copy.provider, equals(original.provider)); expect(copy.createdAt, equals(original.createdAt)); - expect(copy.lastSignInAt, equals('2023-01-02T00:00:00Z')); + expect(copy.lastSignInAt, equals(DateTime.utc(2023, 1, 2))); }); test('preserves original values when no updates provided', () { - const original = UserIdentity( + final original = UserIdentity( id: 'identity-1', userId: '123', identityData: {'email': 'test@example.com'}, identityId: 'identity-1', provider: 'email', - createdAt: '2023-01-01T00:00:00Z', - lastSignInAt: '2023-01-01T00:00:00Z', + createdAt: DateTime.utc(2023, 1, 1), + lastSignInAt: DateTime.utc(2023, 1, 1), ); final copy = original.copyWith(); @@ -665,14 +709,14 @@ void main() { group('toString', () { test('includes all identity properties', () { - const identity = UserIdentity( + final identity = UserIdentity( id: 'identity-1', userId: '123', identityData: {'email': 'test@example.com'}, identityId: 'identity-1', provider: 'email', - createdAt: '2023-01-01T00:00:00Z', - lastSignInAt: '2023-01-01T00:00:00Z', + createdAt: DateTime.utc(2023, 1, 1), + lastSignInAt: DateTime.utc(2023, 1, 1), ); final string = identity.toString(); @@ -686,24 +730,24 @@ void main() { group('equality and hashCode', () { test('returns true for identical identities', () { - const identity1 = UserIdentity( + final identity1 = UserIdentity( id: 'identity-1', userId: '123', identityData: {'email': 'test@example.com'}, identityId: 'identity-1', provider: 'email', - createdAt: '2023-01-01T00:00:00Z', - lastSignInAt: '2023-01-01T00:00:00Z', + createdAt: DateTime.utc(2023, 1, 1), + lastSignInAt: DateTime.utc(2023, 1, 1), ); - const identity2 = UserIdentity( + final identity2 = UserIdentity( id: 'identity-1', userId: '123', identityData: {'email': 'test@example.com'}, identityId: 'identity-1', provider: 'email', - createdAt: '2023-01-01T00:00:00Z', - lastSignInAt: '2023-01-01T00:00:00Z', + createdAt: DateTime.utc(2023, 1, 1), + lastSignInAt: DateTime.utc(2023, 1, 1), ); expect(identity1, equals(identity2)); @@ -711,31 +755,31 @@ void main() { }); test('returns false for identities with different providers', () { - const identity1 = UserIdentity( + final identity1 = UserIdentity( id: 'identity-1', userId: '123', identityData: {'email': 'test@example.com'}, identityId: 'identity-1', provider: 'email', - createdAt: '2023-01-01T00:00:00Z', - lastSignInAt: '2023-01-01T00:00:00Z', + createdAt: DateTime.utc(2023, 1, 1), + lastSignInAt: DateTime.utc(2023, 1, 1), ); - const identity2 = UserIdentity( + final identity2 = UserIdentity( id: 'identity-1', userId: '123', identityData: {'email': 'test@example.com'}, identityId: 'identity-1', provider: 'google', - createdAt: '2023-01-01T00:00:00Z', - lastSignInAt: '2023-01-01T00:00:00Z', + createdAt: DateTime.utc(2023, 1, 1), + lastSignInAt: DateTime.utc(2023, 1, 1), ); expect(identity1, isNot(equals(identity2))); }); test('handles deep map equality correctly', () { - const identity1 = UserIdentity( + final identity1 = UserIdentity( id: 'identity-1', userId: '123', identityData: { @@ -743,11 +787,11 @@ void main() { }, identityId: 'identity-1', provider: 'email', - createdAt: '2023-01-01T00:00:00Z', - lastSignInAt: '2023-01-01T00:00:00Z', + createdAt: DateTime.utc(2023, 1, 1), + lastSignInAt: DateTime.utc(2023, 1, 1), ); - const identity2 = UserIdentity( + final identity2 = UserIdentity( id: 'identity-1', userId: '123', identityData: { @@ -755,8 +799,8 @@ void main() { }, identityId: 'identity-1', provider: 'email', - createdAt: '2023-01-01T00:00:00Z', - lastSignInAt: '2023-01-01T00:00:00Z', + createdAt: DateTime.utc(2023, 1, 1), + lastSignInAt: DateTime.utc(2023, 1, 1), ); expect(identity1, equals(identity2)); @@ -765,7 +809,7 @@ void main() { group('roundtrip serialization', () { test('preserves all data through JSON roundtrip', () { - const original = UserIdentity( + final original = UserIdentity( id: 'identity-1', userId: '123', identityData: { @@ -774,9 +818,9 @@ void main() { }, identityId: 'identity-1', provider: 'email', - createdAt: '2023-01-01T00:00:00Z', - lastSignInAt: '2023-01-01T00:00:00Z', - updatedAt: '2023-01-01T08:00:00Z', + createdAt: DateTime.utc(2023, 1, 1), + lastSignInAt: DateTime.utc(2023, 1, 1), + updatedAt: DateTime.utc(2023, 1, 1, 8), ); final json = original.toJson(); diff --git a/packages/realtime_client/lib/src/types.dart b/packages/realtime_client/lib/src/types.dart index eebc7528b..d91546ab0 100644 --- a/packages/realtime_client/lib/src/types.dart +++ b/packages/realtime_client/lib/src/types.dart @@ -307,10 +307,10 @@ class PostgresChangePayload { DateTime commitTimestamp; try { commitTimestamp = commitTimestampStr != null - ? DateTime.parse(commitTimestampStr) - : DateTime.fromMillisecondsSinceEpoch(0); + ? DateTime.parse(commitTimestampStr).toUtc() + : DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); } on FormatException { - commitTimestamp = DateTime.fromMillisecondsSinceEpoch(0); + commitTimestamp = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); } final newData = payload['new']; diff --git a/packages/storage_client/lib/src/types.dart b/packages/storage_client/lib/src/types.dart index f92dc8b61..1ddf2e368 100644 --- a/packages/storage_client/lib/src/types.dart +++ b/packages/storage_client/lib/src/types.dart @@ -5,8 +5,8 @@ class Bucket { final String id; final String name; final String owner; - final String createdAt; - final String updatedAt; + final DateTime createdAt; + final DateTime updatedAt; final bool public; final int? fileSizeLimit; final List? allowedMimeTypes; @@ -28,8 +28,8 @@ class Bucket { id: json['id'] as String, name: json['name'] as String, owner: json['owner'] as String? ?? '', - createdAt: json['created_at'] as String, - updatedAt: json['updated_at'] as String, + createdAt: parseIso8601(json, 'created_at'), + updatedAt: parseIso8601(json, 'updated_at'), public: json['public'] as bool, fileSizeLimit: json['file_size_limit'] as int?, allowedMimeTypes: allowedMimeTypes is List @@ -65,8 +65,8 @@ class AnalyticsBucket { return AnalyticsBucket( id: json['id'] as String, name: json['name'] as String, - createdAt: DateTime.parse(json['created_at'] as String), - updatedAt: DateTime.parse(json['updated_at'] as String), + createdAt: parseIso8601(json, 'created_at'), + updatedAt: parseIso8601(json, 'updated_at'), ); } } @@ -76,8 +76,8 @@ class FileObject { final String? bucketId; final String? owner; final String? id; - final String? updatedAt; - final String? createdAt; + final DateTime? updatedAt; + final DateTime? createdAt; final Map? metadata; final Bucket? buckets; @@ -104,8 +104,8 @@ class FileObject { name: json['name'] as String, bucketId: json['bucket_id'] as String?, owner: json['owner'] as String?, - updatedAt: json['updated_at'] as String?, - createdAt: json['created_at'] as String?, + updatedAt: tryParseIso8601(json, 'updated_at'), + createdAt: tryParseIso8601(json, 'created_at'), metadata: json['metadata'] as Map?, buckets: bucketsJson is Map ? Bucket.fromJson(bucketsJson) @@ -119,13 +119,13 @@ class FileObjectV2 { final String version; final String name; final String bucketId; - final String? updatedAt; - final String createdAt; + final DateTime? updatedAt; + final DateTime createdAt; final int? size; final String? cacheControl; final String? contentType; final String? etag; - final String? lastModified; + final DateTime? lastModified; final Map? metadata; const FileObjectV2({ @@ -149,13 +149,13 @@ class FileObjectV2 { version: json['version'] as String, name: json['name'] as String, bucketId: json['bucket_id'] as String, - updatedAt: json['updated_at'] as String?, - createdAt: json['created_at'] as String, + updatedAt: tryParseIso8601(json, 'updated_at'), + createdAt: parseIso8601(json, 'created_at'), size: json['size'] as int?, cacheControl: json['cache_control'] as String?, contentType: json['content_type'] as String?, etag: json['etag'] as String?, - lastModified: json['last_modified'] as String?, + lastModified: tryParseIso8601(json, 'last_modified'), metadata: json['metadata'] as Map?, ); } @@ -405,10 +405,10 @@ class PaginatedFile { final String? id; /// The last update timestamp. - final String? updatedAt; + final DateTime? updatedAt; /// The creation timestamp. - final String? createdAt; + final DateTime? createdAt; /// The file metadata, including size and mimetype. `null` when not yet set. final Map? metadata; @@ -427,8 +427,8 @@ class PaginatedFile { name: json['name'] as String, key: json['key'] as String?, id: json['id'] as String?, - updatedAt: json['updated_at'] as String?, - createdAt: json['created_at'] as String?, + updatedAt: tryParseIso8601(json, 'updated_at'), + createdAt: tryParseIso8601(json, 'created_at'), metadata: json['metadata'] as Map?, ); } diff --git a/packages/storage_client/lib/src/vector_types.dart b/packages/storage_client/lib/src/vector_types.dart index 3f1dc806e..f98a3511d 100644 --- a/packages/storage_client/lib/src/vector_types.dart +++ b/packages/storage_client/lib/src/vector_types.dart @@ -1,4 +1,5 @@ import 'package:meta/meta.dart'; +import 'package:supabase_common/supabase_common.dart'; /// Supported data types for vector components. /// @@ -47,12 +48,11 @@ List? _parseFloat32(Object? data) { return float32.map((value) => (value as num).toDouble()).toList(); } +/// Parses a Unix timestamp in seconds, returning `null` for anything the S3 +/// Vectors API sends that is not a number. DateTime? _parseUnixSeconds(Object? value) { if (value is! num) return null; - return DateTime.fromMillisecondsSinceEpoch( - (value * 1000).round(), - isUtc: true, - ); + return dateTimeFromUnixSeconds(value); } /// Encryption settings attached to a vector bucket. diff --git a/packages/storage_client/test/basic_test.dart b/packages/storage_client/test/basic_test.dart index 5431164ef..2c63149d0 100644 --- a/packages/storage_client/test/basic_test.dart +++ b/packages/storage_client/test/basic_test.dart @@ -15,8 +15,8 @@ Map get testBucketJson => { 'id': 'test_bucket', 'name': 'test_bucket', 'owner': 'owner_id', - 'created_at': '', - 'updated_at': '', + 'created_at': '2024-01-01T00:00:00.000Z', + 'updated_at': '2024-01-02T00:00:00.000Z', 'public': false, }; diff --git a/packages/storage_client/test/types_test.dart b/packages/storage_client/test/types_test.dart index 1cbf55a9f..9b70686a9 100644 --- a/packages/storage_client/test/types_test.dart +++ b/packages/storage_client/test/types_test.dart @@ -17,6 +17,8 @@ void main() { expect(bucket.id, 'avatars'); expect(bucket.owner, 'owner-id'); + expect(bucket.createdAt, DateTime.utc(2021, 1, 1)); + expect(bucket.updatedAt, DateTime.utc(2021, 1, 2)); expect(bucket.public, isTrue); expect(bucket.fileSizeLimit, 1024); expect(bucket.allowedMimeTypes, ['image/png', 'image/jpeg']); @@ -36,6 +38,19 @@ void main() { expect(bucket.allowedMimeTypes, isNull); }); + test('throws when created_at is not a valid timestamp', () { + expect( + () => Bucket.fromJson({ + 'id': 'avatars', + 'name': 'avatars', + 'created_at': '', + 'updated_at': '2021-01-02T00:00:00Z', + 'public': false, + }), + throwsFormatException, + ); + }); + test('treats a non-list allowed_mime_types as null', () { final bucket = Bucket.fromJson({ 'id': 'avatars', @@ -78,6 +93,18 @@ void main() { expect(file.buckets, isNull); }); + test('parses the timestamps and leaves absent ones null', () { + final file = FileObject.fromJson({ + 'name': 'photo.png', + 'created_at': '2021-01-01T00:00:00Z', + 'updated_at': '2021-01-02T00:00:00+02:00', + }); + + expect(file.createdAt, DateTime.utc(2021, 1, 1)); + expect(file.updatedAt, DateTime.utc(2021, 1, 1, 22)); + expect(FileObject.fromJson({'name': 'photo.png'}).createdAt, isNull); + }); + test('throws a FormatException when the JSON is not an object', () { expect( () => FileObject.fromJson(['not', 'a', 'map']), @@ -104,7 +131,9 @@ void main() { expect(file.version, 'v1'); expect(file.size, 42); expect(file.contentType, 'image/png'); + expect(file.createdAt, DateTime.utc(2021, 1, 1)); expect(file.updatedAt, isNull); + expect(file.lastModified, isNull); }); }); diff --git a/packages/supabase_common/lib/src/timestamp.dart b/packages/supabase_common/lib/src/timestamp.dart new file mode 100644 index 000000000..d0cf51086 --- /dev/null +++ b/packages/supabase_common/lib/src/timestamp.dart @@ -0,0 +1,64 @@ +/// Parses the ISO 8601 timestamp stored under [key] in [json] as a UTC +/// [DateTime]. +/// +/// Throws a [FormatException] when the value is missing, is not a string, or +/// is not a valid ISO 8601 timestamp. +DateTime parseIso8601(Map json, String key) { + final value = json[key]; + if (value is! String) { + throw FormatException( + 'Expected $key to be a string, got ${value.runtimeType}', + json.toString(), + ); + } + final parsed = DateTime.tryParse(value); + if (parsed == null) { + throw FormatException( + 'Invalid date format for $key: $value', + json.toString(), + ); + } + return parsed.toUtc(); +} + +/// Same as [parseIso8601], but returns `null` when the value under [key] is +/// `null` or absent. +DateTime? tryParseIso8601(Map json, String key) { + if (json[key] == null) return null; + return parseIso8601(json, key); +} + +/// Parses the Unix timestamp in seconds stored under [key] in [json] as a UTC +/// [DateTime]. +/// +/// Throws a [FormatException] when the value is missing or is not a number. +DateTime parseUnixSeconds(Map json, String key) { + final value = json[key]; + if (value is! num) { + throw FormatException( + 'Expected $key to be a number, got ${value.runtimeType}', + json.toString(), + ); + } + return dateTimeFromUnixSeconds(value); +} + +/// Same as [parseUnixSeconds], but returns `null` when the value under [key] is +/// `null` or absent. +DateTime? tryParseUnixSeconds(Map json, String key) { + if (json[key] == null) return null; + return parseUnixSeconds(json, key); +} + +/// Converts a Unix timestamp in [seconds] to a UTC [DateTime]. +DateTime dateTimeFromUnixSeconds(num seconds) { + return DateTime.fromMillisecondsSinceEpoch( + (seconds * 1000).round(), + isUtc: true, + ); +} + +/// The number of whole seconds between [dateTime] and the Unix epoch. +int unixSecondsFromDateTime(DateTime dateTime) { + return dateTime.millisecondsSinceEpoch ~/ 1000; +} diff --git a/packages/supabase_common/lib/supabase_common.dart b/packages/supabase_common/lib/supabase_common.dart index ae17fc531..ceade8799 100644 --- a/packages/supabase_common/lib/supabase_common.dart +++ b/packages/supabase_common/lib/supabase_common.dart @@ -14,4 +14,5 @@ export 'src/platform/platform_info.dart'; export 'src/replay_subject.dart'; export 'src/retry.dart'; export 'src/snake_case.dart'; +export 'src/timestamp.dart'; export 'src/uuid.dart'; diff --git a/packages/supabase_common/test/timestamp_test.dart b/packages/supabase_common/test/timestamp_test.dart new file mode 100644 index 000000000..c61303064 --- /dev/null +++ b/packages/supabase_common/test/timestamp_test.dart @@ -0,0 +1,148 @@ +import 'package:supabase_common/supabase_common.dart'; +import 'package:test/test.dart'; + +void main() { + group('parseIso8601', () { + test('parses a UTC timestamp', () { + expect( + parseIso8601({ + 'created_at': '2023-04-01T09:38:59.784028Z', + }, 'created_at'), + DateTime.utc(2023, 4, 1, 9, 38, 59, 784, 28), + ); + }); + + test('normalizes a timestamp with an offset to UTC', () { + final parsed = parseIso8601({ + 'created_at': '2023-04-01T11:00:00+02:00', + }, 'created_at'); + + expect(parsed.isUtc, isTrue); + expect(parsed, DateTime.utc(2023, 4, 1, 9)); + }); + + test('normalizes a timestamp without a zone designator to UTC', () { + final parsed = parseIso8601({ + 'created_at': '2023-04-01T09:00:00', + }, 'created_at'); + + expect(parsed.isUtc, isTrue); + expect(parsed, DateTime(2023, 4, 1, 9).toUtc()); + }); + + test('throws when the value is missing', () { + expect( + () => parseIso8601({}, 'created_at'), + throwsA( + isA().having( + (exception) => exception.message, + 'message', + 'Expected created_at to be a string, got Null', + ), + ), + ); + }); + + test('throws when the value is not a string', () { + expect( + () => parseIso8601({'created_at': 1735689600}, 'created_at'), + throwsFormatException, + ); + }); + + test('throws when the value is not a valid timestamp', () { + expect( + () => parseIso8601({'created_at': 'yesterday'}, 'created_at'), + throwsA( + isA().having( + (exception) => exception.message, + 'message', + 'Invalid date format for created_at: yesterday', + ), + ), + ); + }); + }); + + group('tryParseIso8601', () { + test('returns null when the value is null', () { + expect(tryParseIso8601({'created_at': null}, 'created_at'), isNull); + }); + + test('returns null when the value is absent', () { + expect(tryParseIso8601({}, 'created_at'), isNull); + }); + + test('parses a present value', () { + expect( + tryParseIso8601({'created_at': '2023-04-01T09:00:00Z'}, 'created_at'), + DateTime.utc(2023, 4, 1, 9), + ); + }); + + test('throws when a present value is not a valid timestamp', () { + expect( + () => tryParseIso8601({'created_at': 'yesterday'}, 'created_at'), + throwsFormatException, + ); + }); + }); + + group('parseUnixSeconds', () { + test('parses whole seconds as UTC', () { + final parsed = parseUnixSeconds({'expires_at': 1735689600}, 'expires_at'); + + expect(parsed.isUtc, isTrue); + expect(parsed, DateTime.utc(2025, 1, 1)); + }); + + test('rounds fractional seconds to the nearest millisecond', () { + expect( + parseUnixSeconds({'expires_at': 1735689600.4567}, 'expires_at'), + DateTime.utc(2025, 1, 1, 0, 0, 0, 457), + ); + }); + + test('throws when the value is not a number', () { + expect( + () => parseUnixSeconds({'expires_at': '1735689600'}, 'expires_at'), + throwsA( + isA().having( + (exception) => exception.message, + 'message', + 'Expected expires_at to be a number, got String', + ), + ), + ); + }); + }); + + group('tryParseUnixSeconds', () { + test('returns null when the value is null', () { + expect(tryParseUnixSeconds({'expires_at': null}, 'expires_at'), isNull); + }); + + test('parses a present value', () { + expect( + tryParseUnixSeconds({'expires_at': 1735689600}, 'expires_at'), + DateTime.utc(2025, 1, 1), + ); + }); + }); + + group('dateTimeFromUnixSeconds and unixSecondsFromDateTime', () { + test('round trip whole seconds', () { + expect( + unixSecondsFromDateTime(dateTimeFromUnixSeconds(1735689600)), + 1735689600, + ); + }); + + test('unixSecondsFromDateTime truncates sub-second precision', () { + expect( + unixSecondsFromDateTime(DateTime.utc(2025, 1, 1, 0, 0, 0, 999)), + 1735689600, + ); + }); + }); +} diff --git a/sdk-compliance.yaml b/sdk-compliance.yaml index d69a03d63..81ff2633c 100644 --- a/sdk-compliance.yaml +++ b/sdk-compliance.yaml @@ -208,6 +208,11 @@ features: - OAuthAuthorizedClient.clientId - OAuthAuthorizedClient.clientName - OAuthAuthorizedClient.fromJson + - OAuthAuthorizingUser + - OAuthAuthorizingUser.OAuthAuthorizingUser + - OAuthAuthorizingUser.email + - OAuthAuthorizingUser.fromJson + - OAuthAuthorizingUser.id auth.oauth_server.approve_authorization: status: implemented symbols: From 7b8a7fb915b538d4a68a9f5d0447259e384a55a5 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Thu, 6 Aug 2026 15:10:57 +0200 Subject: [PATCH 2/6] fix: reject out-of-range dates and cover the UTC fallbacks DateTime.tryParse carries out-of-range components over into the next larger one instead of rejecting them, so parseIso8601 accepted '2020-01-42' as 2020-02-11 and '2019-02-29' as 2019-03-01. Reject those so a malformed payload surfaces instead of a plausible wrong timestamp. Also adds the tests for the UTC handling in PostgresChangePayload and the vector index creationTime, both of which changed in this branch without direct coverage. --- packages/realtime_client/test/types_test.dart | 50 ++++++++++++++++ packages/storage_client/test/vector_test.dart | 40 +++++++++++++ .../supabase_common/lib/src/timestamp.dart | 26 ++++++++- .../supabase_common/test/timestamp_test.dart | 57 +++++++++++++++++++ 4 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 packages/realtime_client/test/types_test.dart diff --git a/packages/realtime_client/test/types_test.dart b/packages/realtime_client/test/types_test.dart new file mode 100644 index 000000000..84e54b5bf --- /dev/null +++ b/packages/realtime_client/test/types_test.dart @@ -0,0 +1,50 @@ +import 'package:realtime_client/realtime_client.dart'; +import 'package:test/test.dart'; + +Map payloadWith(Object? commitTimestamp) => { + 'schema': 'public', + 'table': 'messages', + 'commit_timestamp': commitTimestamp, + 'eventType': 'INSERT', + 'new': {'id': 1}, + 'old': {}, + 'errors': null, +}; + +void main() { + group('PostgresChangePayload.fromPayload', () { + test('parses a commit timestamp as UTC', () { + final payload = PostgresChangePayload.fromPayload( + payloadWith('2022-09-21T04:59:30Z'), + ); + + expect(payload.commitTimestamp, DateTime.utc(2022, 9, 21, 4, 59, 30)); + expect(payload.commitTimestamp.isUtc, isTrue); + }); + + test('normalizes a commit timestamp with an offset to UTC', () { + final payload = PostgresChangePayload.fromPayload( + payloadWith('2022-09-21T06:59:30+02:00'), + ); + + expect(payload.commitTimestamp, DateTime.utc(2022, 9, 21, 4, 59, 30)); + expect(payload.commitTimestamp.isUtc, isTrue); + }); + + test('falls back to the UTC epoch when the timestamp is missing', () { + final payload = PostgresChangePayload.fromPayload(payloadWith(null)); + + expect(payload.commitTimestamp, DateTime.utc(1970)); + expect(payload.commitTimestamp.isUtc, isTrue); + }); + + test('falls back to the UTC epoch when the timestamp is malformed', () { + final payload = PostgresChangePayload.fromPayload( + payloadWith('not a timestamp'), + ); + + expect(payload.commitTimestamp, DateTime.utc(1970)); + expect(payload.commitTimestamp.isUtc, isTrue); + }); + }); +} diff --git a/packages/storage_client/test/vector_test.dart b/packages/storage_client/test/vector_test.dart index 398b66805..91f81c767 100644 --- a/packages/storage_client/test/vector_test.dart +++ b/packages/storage_client/test/vector_test.dart @@ -86,6 +86,19 @@ void main() { expect(result.nextToken, 'cursor-1'); }); + test('getBucket leaves a non-numeric creationTime null', () async { + mockClient.response = { + 'vectorBucket': { + 'vectorBucketName': 'embeddings', + 'creationTime': '2023-11-14T22:13:20Z', + }, + }; + + final bucket = await vectors.getBucket('embeddings'); + + expect(bucket.creationTime, isNull); + }); + test('deleteBucket posts the bucket name', () async { await vectors.deleteBucket('embeddings'); @@ -148,6 +161,33 @@ void main() { expect(index.nonFilterableMetadataKeys, ['raw_text']); }); + test('getIndex parses creationTime as UTC', () async { + mockClient.response = { + 'index': { + 'indexName': 'documents', + 'creationTime': 1700000000, + }, + }; + + final index = await vectors.from('embeddings').getIndex('documents'); + + expect(index.creationTime, DateTime.utc(2023, 11, 14, 22, 13, 20)); + expect(index.creationTime!.isUtc, isTrue); + }); + + test('getIndex leaves a non-numeric creationTime null', () async { + mockClient.response = { + 'index': { + 'indexName': 'documents', + 'creationTime': '2023-11-14T22:13:20Z', + }, + }; + + final index = await vectors.from('embeddings').getIndex('documents'); + + expect(index.creationTime, isNull); + }); + test('getIndex leaves unknown enum values null', () async { mockClient.response = { 'index': { diff --git a/packages/supabase_common/lib/src/timestamp.dart b/packages/supabase_common/lib/src/timestamp.dart index d0cf51086..799e77fb2 100644 --- a/packages/supabase_common/lib/src/timestamp.dart +++ b/packages/supabase_common/lib/src/timestamp.dart @@ -2,7 +2,9 @@ /// [DateTime]. /// /// Throws a [FormatException] when the value is missing, is not a string, or -/// is not a valid ISO 8601 timestamp. +/// is not a valid ISO 8601 timestamp. Unlike [DateTime.parse], a date whose +/// month or day is out of range is rejected rather than carried over into the +/// next month or year. DateTime parseIso8601(Map json, String key) { final value = json[key]; if (value is! String) { @@ -12,7 +14,7 @@ DateTime parseIso8601(Map json, String key) { ); } final parsed = DateTime.tryParse(value); - if (parsed == null) { + if (parsed == null || !_hasValidCalendarDate(value)) { throw FormatException( 'Invalid date format for $key: $value', json.toString(), @@ -21,6 +23,26 @@ DateTime parseIso8601(Map json, String key) { return parsed.toUtc(); } +final _calendarDatePattern = RegExp( + r'^(?:(\d{4})-(\d{2})-(\d{2})|(\d{4})(\d{2})(\d{2}))', +); + +/// Whether the calendar date at the start of [value] is a real date. +/// +/// [DateTime.tryParse] carries out-of-range components over into the next +/// larger one instead of rejecting them, so `2020-01-42` parses as 2020-02-11 +/// and `2019-02-29` as 2019-03-01. A timestamp that does not name a real date +/// is a malformed payload rather than a timestamp days later. +bool _hasValidCalendarDate(String value) { + final match = _calendarDatePattern.firstMatch(value); + if (match == null) return true; + final year = int.parse(match[1] ?? match[4]!); + final month = int.parse(match[2] ?? match[5]!); + final day = int.parse(match[3] ?? match[6]!); + final carried = DateTime.utc(year, month, day); + return carried.month == month && carried.day == day; +} + /// Same as [parseIso8601], but returns `null` when the value under [key] is /// `null` or absent. DateTime? tryParseIso8601(Map json, String key) { diff --git a/packages/supabase_common/test/timestamp_test.dart b/packages/supabase_common/test/timestamp_test.dart index c61303064..69ee52344 100644 --- a/packages/supabase_common/test/timestamp_test.dart +++ b/packages/supabase_common/test/timestamp_test.dart @@ -62,6 +62,63 @@ void main() { ), ); }); + + test('throws instead of carrying an out-of-range day into a real one', () { + // DateTime.parse would return 2020-02-11 for this. + expect( + () => parseIso8601({'created_at': '2020-01-42'}, 'created_at'), + throwsFormatException, + ); + expect( + () => + parseIso8601({'created_at': '2025-02-30T10:00:00Z'}, 'created_at'), + throwsFormatException, + ); + expect( + () => + parseIso8601({'created_at': '2019-02-29T10:00:00Z'}, 'created_at'), + throwsFormatException, + ); + expect( + () => parseIso8601({'created_at': '2020-01-00'}, 'created_at'), + throwsFormatException, + ); + }); + + test( + 'throws instead of carrying an out-of-range month into a real one', + () { + expect( + () => parseIso8601({'created_at': '2023-13-01'}, 'created_at'), + throwsFormatException, + ); + expect( + () => parseIso8601({'created_at': '2023-00-01'}, 'created_at'), + throwsFormatException, + ); + }, + ); + + test('accepts a leap day in a leap year', () { + expect( + parseIso8601({'created_at': '2020-02-29T10:00:00Z'}, 'created_at'), + DateTime.utc(2020, 2, 29, 10), + ); + }); + + test('accepts the basic format the ISO 8601 parser allows', () { + expect( + parseIso8601({'created_at': '20230401T090000Z'}, 'created_at'), + DateTime.utc(2023, 4, 1, 9), + ); + }); + + test('rejects an out-of-range day in the basic format', () { + expect( + () => parseIso8601({'created_at': '20230442T090000Z'}, 'created_at'), + throwsFormatException, + ); + }); }); group('tryParseIso8601', () { From 128cad01e6fb21042d063de89a7e62614bfdeba3 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Thu, 6 Aug 2026 16:04:45 +0200 Subject: [PATCH 3/6] fix: floor pre-epoch Unix seconds and validate the OAuth user fields unixSecondsFromDateTime truncated towards zero, so an instant before the Unix epoch landed in a second that does not contain it. OAuthAuthorizingUser.fromJson cast its two required fields, which raises a TypeError for a malformed payload where the enclosing parse contract promises a FormatException. Also corrects the MIGRATION.md wire-format note: the storage types have no toJson, in v2 or v3, so only Session, User and UserIdentity are relevant to the claim. --- MIGRATION.md | 12 ++++--- packages/gotrue/lib/src/gotrue_oauth_api.dart | 14 +++++--- .../test/src/gotrue_oauth_api_test.dart | 36 +++++++++++++++++++ .../supabase_common/lib/src/timestamp.dart | 8 +++-- .../supabase_common/test/timestamp_test.dart | 19 +++++++++- 5 files changed, 78 insertions(+), 11 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index e2f1a7b97..a3819e39c 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -152,17 +152,21 @@ final iso = user.createdAt.toIso8601String(); final unixSeconds = session.expiresAt!.millisecondsSinceEpoch ~/ 1000; ``` -The wire format is unchanged: `toJson()` still writes ISO 8601 strings for the `User`, `Bucket` and -`FileObject` timestamps and Unix seconds for `Session.expires_at`, so persisted sessions written by -v2 are still readable by v3. +The wire format is unchanged. Of the types above only `Session`, `User` and `UserIdentity` have a +`toJson()`, and those still write ISO 8601 strings for the `User` and `UserIdentity` timestamps and +Unix seconds for `Session.expires_at`, so sessions persisted by v2 are still readable by v3. The +storage types have no `toJson()`, in v2 or v3. -Two behavioural details are worth checking: +Three behavioural details are worth checking: - The `DateTime` values are in UTC. `DateTime` equality takes the time zone flag into account, so compare against `DateTime.utc(...)` rather than `DateTime(...)`, or call `toLocal()` first. - A timestamp the server is documented to always send is now parsed strictly. `User.createdAt` used to fall back to an empty string when the field was missing and now throws a `FormatException`, which surfaces a malformed payload instead of passing an unusable value on. +- A timestamp naming a date that does not exist is rejected rather than rolled forward. + `DateTime.parse` reads `2019-02-29` as 1 March 2019; parsing now throws a `FormatException` + instead. ### `OAuthAuthorizationDetailsResponse.user` is an `OAuthAuthorizingUser` diff --git a/packages/gotrue/lib/src/gotrue_oauth_api.dart b/packages/gotrue/lib/src/gotrue_oauth_api.dart index 0324b7b65..a6e9f63b9 100644 --- a/packages/gotrue/lib/src/gotrue_oauth_api.dart +++ b/packages/gotrue/lib/src/gotrue_oauth_api.dart @@ -50,10 +50,16 @@ class OAuthAuthorizingUser { const OAuthAuthorizingUser({required this.id, required this.email}); factory OAuthAuthorizingUser.fromJson(Map json) { - return OAuthAuthorizingUser( - id: json['id'] as String, - email: json['email'] as String, - ); + final id = json['id']; + final email = json['email']; + if (id is! String || email is! String) { + throw FormatException( + 'Expected the user id and email to be strings, got ' + '${id.runtimeType} and ${email.runtimeType}', + json.toString(), + ); + } + return OAuthAuthorizingUser(id: id, email: email); } } diff --git a/packages/gotrue/test/src/gotrue_oauth_api_test.dart b/packages/gotrue/test/src/gotrue_oauth_api_test.dart index dd4fc2f88..f0dd16b9a 100644 --- a/packages/gotrue/test/src/gotrue_oauth_api_test.dart +++ b/packages/gotrue/test/src/gotrue_oauth_api_test.dart @@ -67,6 +67,42 @@ void main() { ); }); + test('throws FormatException when the user id is not a string', () { + final json = { + 'authorization_id': '6abuj667j4nmdotzu3w2ro5r33xezvae', + 'redirect_uri': 'http://localhost:50200/onboarding/auth/consent', + 'client': { + 'id': '7263e727-435b-4d38-a5ff-a14c954b8680', + 'name': 'OAuth test client', + }, + 'user': {'id': 42, 'email': 'translator.user@mail.com'}, + 'scope': 'email', + }; + + expect( + () => OAuthAuthorizationDetailsResponse.fromJson(json), + throwsFormatException, + ); + }); + + test('throws FormatException when the user email is missing', () { + final json = { + 'authorization_id': '6abuj667j4nmdotzu3w2ro5r33xezvae', + 'redirect_uri': 'http://localhost:50200/onboarding/auth/consent', + 'client': { + 'id': '7263e727-435b-4d38-a5ff-a14c954b8680', + 'name': 'OAuth test client', + }, + 'user': {'id': '1bee2038-51fe-4f93-8fbb-442df18657ff'}, + 'scope': 'email', + }; + + expect( + () => OAuthAuthorizationDetailsResponse.fromJson(json), + throwsFormatException, + ); + }); + test('throws FormatException when user information is missing', () { final json = { 'authorization_id': '6abuj667j4nmdotzu3w2ro5r33xezvae', diff --git a/packages/supabase_common/lib/src/timestamp.dart b/packages/supabase_common/lib/src/timestamp.dart index 799e77fb2..94f0bc064 100644 --- a/packages/supabase_common/lib/src/timestamp.dart +++ b/packages/supabase_common/lib/src/timestamp.dart @@ -80,7 +80,11 @@ DateTime dateTimeFromUnixSeconds(num seconds) { ); } -/// The number of whole seconds between [dateTime] and the Unix epoch. +/// The Unix timestamp of [dateTime] in whole seconds. +/// +/// Sub-second precision is floored rather than truncated towards zero, so the +/// result stays the second that contains [dateTime] for instants before the +/// Unix epoch too. int unixSecondsFromDateTime(DateTime dateTime) { - return dateTime.millisecondsSinceEpoch ~/ 1000; + return (dateTime.millisecondsSinceEpoch / 1000).floor(); } diff --git a/packages/supabase_common/test/timestamp_test.dart b/packages/supabase_common/test/timestamp_test.dart index 69ee52344..6a5b6a936 100644 --- a/packages/supabase_common/test/timestamp_test.dart +++ b/packages/supabase_common/test/timestamp_test.dart @@ -195,11 +195,28 @@ void main() { ); }); - test('unixSecondsFromDateTime truncates sub-second precision', () { + test('unixSecondsFromDateTime floors sub-second precision', () { expect( unixSecondsFromDateTime(DateTime.utc(2025, 1, 1, 0, 0, 0, 999)), 1735689600, ); }); + + test('unixSecondsFromDateTime floors before the epoch too', () { + // Truncating towards zero would give 0 and -1 here, seconds that do not + // contain these instants. + expect( + unixSecondsFromDateTime(DateTime.utc(1969, 12, 31, 23, 59, 59, 500)), + -1, + ); + expect( + unixSecondsFromDateTime(DateTime.utc(1969, 12, 31, 23, 59, 58, 500)), + -2, + ); + }); + + test('round trip an instant before the epoch', () { + expect(unixSecondsFromDateTime(dateTimeFromUnixSeconds(-2)), -2); + }); }); } From a452681a97ba0b33dbf25222932ccb44a53aa29b Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Thu, 6 Aug 2026 16:15:24 +0200 Subject: [PATCH 4/6] fix: fall back for a non-string commit_timestamp and spell out locals Casting commit_timestamp to String? raised a TypeError for a non-null non-string value, which the FormatException fallback around the parse does not catch, so a realtime payload with a numeric timestamp threw instead of falling back to the epoch. Type-check the value instead of casting it. Also spells out the abbreviated locals this branch introduced: exp becomes expiresAtSeconds and commitTimestampStr becomes commitTimestampValue. --- MIGRATION.md | 2 +- packages/gotrue/lib/src/types/session.dart | 6 ++++-- packages/gotrue/test/src/types/session_test.dart | 8 +++++--- packages/realtime_client/lib/src/types.dart | 6 +++--- packages/realtime_client/test/types_test.dart | 11 +++++++++++ 5 files changed, 24 insertions(+), 9 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index a3819e39c..3ebba88d3 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -148,7 +148,7 @@ final createdAt = user.createdAt; If you need the previous representation, ask for it explicitly: ```dart -final iso = user.createdAt.toIso8601String(); +final isoString = user.createdAt.toIso8601String(); final unixSeconds = session.expiresAt!.millisecondsSinceEpoch ~/ 1000; ``` diff --git a/packages/gotrue/lib/src/types/session.dart b/packages/gotrue/lib/src/types/session.dart index 1678ae2cd..414776012 100644 --- a/packages/gotrue/lib/src/types/session.dart +++ b/packages/gotrue/lib/src/types/session.dart @@ -81,8 +81,10 @@ class Session { DateTime? get _expiresAt { try { - final exp = decodeJwtPayload(accessToken).exp; - return exp == null ? null : dateTimeFromUnixSeconds(exp); + final expiresAtSeconds = decodeJwtPayload(accessToken).exp; + return expiresAtSeconds == null + ? null + : dateTimeFromUnixSeconds(expiresAtSeconds); } catch (_) { return null; } diff --git a/packages/gotrue/test/src/types/session_test.dart b/packages/gotrue/test/src/types/session_test.dart index 23217a61a..f0fedd04d 100644 --- a/packages/gotrue/test/src/types/session_test.dart +++ b/packages/gotrue/test/src/types/session_test.dart @@ -145,16 +145,18 @@ void main() { }); test('serializes expiresAt as Unix seconds', () { - final exp = 1700000000; + final expiresAtSeconds = 1700000000; final header = base64Encode(utf8.encode('{"alg":"HS256","typ":"JWT"}')); - final payload = base64Encode(utf8.encode('{"exp":$exp}')); + final payload = base64Encode( + utf8.encode('{"exp":$expiresAtSeconds}'), + ); final session = Session( accessToken: '$header.$payload.signature', tokenType: 'bearer', user: mockUser, ); - expect(session.toJson()['expires_at'], equals(exp)); + expect(session.toJson()['expires_at'], equals(expiresAtSeconds)); }); test('serializes expires_at as null when the JWT has no expiry', () { diff --git a/packages/realtime_client/lib/src/types.dart b/packages/realtime_client/lib/src/types.dart index d91546ab0..efa2be8f1 100644 --- a/packages/realtime_client/lib/src/types.dart +++ b/packages/realtime_client/lib/src/types.dart @@ -303,11 +303,11 @@ class PostgresChangePayload { /// Creates a PostgresChangePayload instance from the enriched postgres change /// payload factory PostgresChangePayload.fromPayload(Map payload) { - final commitTimestampStr = payload['commit_timestamp'] as String?; + final commitTimestampValue = payload['commit_timestamp']; DateTime commitTimestamp; try { - commitTimestamp = commitTimestampStr != null - ? DateTime.parse(commitTimestampStr).toUtc() + commitTimestamp = commitTimestampValue is String + ? DateTime.parse(commitTimestampValue).toUtc() : DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); } on FormatException { commitTimestamp = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); diff --git a/packages/realtime_client/test/types_test.dart b/packages/realtime_client/test/types_test.dart index 84e54b5bf..92acdc715 100644 --- a/packages/realtime_client/test/types_test.dart +++ b/packages/realtime_client/test/types_test.dart @@ -46,5 +46,16 @@ void main() { expect(payload.commitTimestamp, DateTime.utc(1970)); expect(payload.commitTimestamp.isUtc, isTrue); }); + + test('falls back to the UTC epoch when the timestamp is not a string', () { + // Casting this to String? raised a TypeError, which the FormatException + // fallback around the parse does not catch. + final payload = PostgresChangePayload.fromPayload( + payloadWith(1663764570), + ); + + expect(payload.commitTimestamp, DateTime.utc(1970)); + expect(payload.commitTimestamp.isUtc, isTrue); + }); }); } From cf61c3e09036d155fbef1f2052e5211b1c86e7c7 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Thu, 6 Aug 2026 17:05:18 +0200 Subject: [PATCH 5/6] docs: drop the removed deprecated fields from the timestamp table The deprecated API removal on main deletes User.confirmedAt and both lastAccessedAt fields, so listing them as converted to DateTime contradicted the removal section further down the same guide. --- MIGRATION.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index 3ebba88d3..7b87bebde 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -123,13 +123,13 @@ doing arithmetic on them no longer requires parsing them yourself. | --- | --- | --- | --- | | `Session` | `expiresAt` | `int?` (Unix seconds) | `DateTime?` | | `User` | `createdAt` | `String` | `DateTime` | -| `User` | `confirmationSentAt`, `recoverySentAt`, `emailChangeSentAt`, `invitedAt`, `confirmedAt`, `emailConfirmedAt`, `phoneConfirmedAt`, `lastSignInAt`, `updatedAt` | `String?` | `DateTime?` | +| `User` | `confirmationSentAt`, `recoverySentAt`, `emailChangeSentAt`, `invitedAt`, `emailConfirmedAt`, `phoneConfirmedAt`, `lastSignInAt`, `updatedAt` | `String?` | `DateTime?` | | `UserIdentity` | `createdAt`, `lastSignInAt`, `updatedAt` | `String?` | `DateTime?` | | `OAuthClient` | `createdAt`, `updatedAt` | `String` | `DateTime` | | `Bucket` | `createdAt`, `updatedAt` | `String` | `DateTime` | -| `FileObject` | `createdAt`, `updatedAt`, `lastAccessedAt` | `String?` | `DateTime?` | +| `FileObject` | `createdAt`, `updatedAt` | `String?` | `DateTime?` | | `FileObjectV2` | `createdAt` | `String` | `DateTime` | -| `FileObjectV2` | `updatedAt`, `lastAccessedAt`, `lastModified` | `String?` | `DateTime?` | +| `FileObjectV2` | `updatedAt`, `lastModified` | `String?` | `DateTime?` | | `PaginatedFile` | `createdAt`, `updatedAt` | `String?` | `DateTime?` | ```dart From 38c4395c4bee61466f009043e6e2eef648c37d05 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Fri, 7 Aug 2026 09:53:37 +0200 Subject: [PATCH 6/6] fix: keep the parsed payload out of timestamp parse exceptions The timestamp helpers passed the whole JSON object as the FormatException source. FormatException.toString() prints part of it, and User.fromJson payloads carry an email, a phone number and user metadata, so a malformed timestamp put personal data into the exception text. supabase_flutter logs that text when session recovery fails. The messages already name the key and the offending value, which is what a malformed timestamp needs, so the source is dropped. The two OAuth user parse errors get the same treatment, naming the unexpected type instead of dumping a payload that contains the user's email. Also narrows the parseIso8601 doc comment to what the validation does: it covers the YYYY-MM-DD and YYYYMMDD forms, not the expanded year form. --- packages/gotrue/lib/src/gotrue_oauth_api.dart | 5 ++-- .../supabase_common/lib/src/timestamp.dart | 24 ++++++++++++------- .../supabase_common/test/timestamp_test.dart | 19 +++++++++++++++ 3 files changed, 36 insertions(+), 12 deletions(-) diff --git a/packages/gotrue/lib/src/gotrue_oauth_api.dart b/packages/gotrue/lib/src/gotrue_oauth_api.dart index a6e9f63b9..3c9af085c 100644 --- a/packages/gotrue/lib/src/gotrue_oauth_api.dart +++ b/packages/gotrue/lib/src/gotrue_oauth_api.dart @@ -56,7 +56,6 @@ class OAuthAuthorizingUser { throw FormatException( 'Expected the user id and email to be strings, got ' '${id.runtimeType} and ${email.runtimeType}', - json.toString(), ); } return OAuthAuthorizingUser(id: id, email: email); @@ -151,8 +150,8 @@ class OAuthAuthorizationDetailsResponse extends OAuthAuthorizationResponse { final user = json['user']; if (user is! Map) { throw FormatException( - 'The provided JSON should contain a parseable user object', - json.toString(), + 'Expected the authorization details to contain a user object, got ' + '${user.runtimeType}', ); } diff --git a/packages/supabase_common/lib/src/timestamp.dart b/packages/supabase_common/lib/src/timestamp.dart index 94f0bc064..5c00f1e11 100644 --- a/packages/supabase_common/lib/src/timestamp.dart +++ b/packages/supabase_common/lib/src/timestamp.dart @@ -2,23 +2,24 @@ /// [DateTime]. /// /// Throws a [FormatException] when the value is missing, is not a string, or -/// is not a valid ISO 8601 timestamp. Unlike [DateTime.parse], a date whose -/// month or day is out of range is rejected rather than carried over into the -/// next month or year. +/// is not a valid ISO 8601 timestamp. For the `YYYY-MM-DD` and `YYYYMMDD` +/// forms the servers send, a month or day that is out of range is rejected +/// rather than carried over into the next month or year the way +/// [DateTime.parse] carries it. +/// +/// The exception names the key and the offending value but does not carry +/// [json] itself, which holds the caller's payload and so may contain personal +/// data the caller would not expect in an error or a log. DateTime parseIso8601(Map json, String key) { final value = json[key]; if (value is! String) { throw FormatException( 'Expected $key to be a string, got ${value.runtimeType}', - json.toString(), ); } final parsed = DateTime.tryParse(value); if (parsed == null || !_hasValidCalendarDate(value)) { - throw FormatException( - 'Invalid date format for $key: $value', - json.toString(), - ); + throw FormatException('Invalid date format for $key: $value'); } return parsed.toUtc(); } @@ -33,6 +34,11 @@ final _calendarDatePattern = RegExp( /// larger one instead of rejecting them, so `2020-01-42` parses as 2020-02-11 /// and `2019-02-29` as 2019-03-01. A timestamp that does not name a real date /// is a malformed payload rather than a timestamp days later. +/// +/// Only the `YYYY-MM-DD` and `YYYYMMDD` forms are checked. Anything else +/// [DateTime.tryParse] accepts, such as the expanded year form +/// `+002023-01-42`, keeps its carrying behaviour; no Supabase service sends +/// those. bool _hasValidCalendarDate(String value) { final match = _calendarDatePattern.firstMatch(value); if (match == null) return true; @@ -54,12 +60,12 @@ DateTime? tryParseIso8601(Map json, String key) { /// [DateTime]. /// /// Throws a [FormatException] when the value is missing or is not a number. +/// As in [parseIso8601], the exception does not carry [json] itself. DateTime parseUnixSeconds(Map json, String key) { final value = json[key]; if (value is! num) { throw FormatException( 'Expected $key to be a number, got ${value.runtimeType}', - json.toString(), ); } return dateTimeFromUnixSeconds(value); diff --git a/packages/supabase_common/test/timestamp_test.dart b/packages/supabase_common/test/timestamp_test.dart index 6a5b6a936..e49f7fe7d 100644 --- a/packages/supabase_common/test/timestamp_test.dart +++ b/packages/supabase_common/test/timestamp_test.dart @@ -63,6 +63,25 @@ void main() { ); }); + test('does not carry the payload into the exception', () { + // The payload can hold personal data, and these exceptions get logged. + expect( + () => parseIso8601({ + 'email': 'jane.doe@example.com', + 'created_at': 'yesterday', + }, 'created_at'), + throwsA( + isA() + .having((exception) => exception.source, 'source', isNull) + .having( + (exception) => exception.toString(), + 'toString', + isNot(contains('jane.doe@example.com')), + ), + ), + ); + }); + test('throws instead of carrying an out-of-range day into a real one', () { // DateTime.parse would return 2020-02-11 for this. expect(