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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,78 @@ 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`, `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` | `String?` | `DateTime?` |
| `FileObjectV2` | `createdAt` | `String` | `DateTime` |
| `FileObjectV2` | `updatedAt`, `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 isoString = user.createdAt.toIso8601String();
final unixSeconds = session.expiresAt!.millisecondsSinceEpoch ~/ 1000;
```

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.

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`

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
Expand Down
8 changes: 2 additions & 6 deletions examples/storage_transforms/lib/models.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 1 addition & 3 deletions packages/gotrue/lib/src/gotrue_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1391,9 +1391,7 @@ class GoTrueClient {
}

final expiresInTicks =
(DateTime.fromMillisecondsSinceEpoch(
expiresAt * 1000,
).difference(now).inMilliseconds /
(expiresAt.difference(now).inMilliseconds /
Constants.autoRefreshTickDuration.inMilliseconds)
.floor();

Expand Down
45 changes: 36 additions & 9 deletions packages/gotrue/lib/src/gotrue_oauth_api.dart
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,34 @@ 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<String, dynamic> json) {
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}',
);
}
return OAuthAuthorizingUser(id: id, email: email);
}
}

/// 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.
Expand All @@ -57,7 +85,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'),
);
}
}
Expand Down Expand Up @@ -99,8 +127,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;
Expand All @@ -119,19 +147,18 @@ class OAuthAuthorizationDetailsResponse extends OAuthAuthorizationResponse {
factory OAuthAuthorizationDetailsResponse.fromJson(
Map<String, dynamic> json,
) {
final user = json['user'] == null ? null : User.fromJson(json['user']);

if (user == null) {
final user = json['user'];
if (user is! Map<String, dynamic>) {
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}',
);
}

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,
);
Expand Down
6 changes: 4 additions & 2 deletions packages/gotrue/lib/src/types/custom_oauth_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -208,8 +210,8 @@ class CustomOAuthProvider {
: OIDCDiscoveryDocument.fromJson(
discoveryDocument as Map<String, dynamic>,
),
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'),
);
}
}
Expand Down
43 changes: 5 additions & 38 deletions packages/gotrue/lib/src/types/mfa.dart
Original file line number Diff line number Diff line change
@@ -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).
Expand Down Expand Up @@ -107,17 +108,9 @@ class AuthMFAChallengeResponse {
const AuthMFAChallengeResponse({required this.id, required this.expiresAt});

factory AuthMFAChallengeResponse.fromJson(Map<String, dynamic> 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'),
);
}
}
Expand Down Expand Up @@ -283,24 +276,6 @@ class Factor {
});

factory Factor.fromJson(Map<String, dynamic> 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?,
Expand All @@ -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'),
);
}

Expand Down Expand Up @@ -431,20 +406,12 @@ class AMREntry {
const AMREntry({required this.method, required this.timestamp});

factory AMREntry.fromJson(Map<String, dynamic> 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'),
);
}
}
28 changes: 5 additions & 23 deletions packages/gotrue/lib/src/types/passkey.dart
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -27,19 +28,11 @@ class Passkey {
});

factory Passkey.fromJson(Map<String, dynamic> 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'),
);
}

Expand Down Expand Up @@ -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'),
);
}
}
Expand Down Expand Up @@ -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<String, dynamic> 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);
}
Loading
Loading