Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: Added missing error codes for AuthException #995

Merged
merged 11 commits into from
Aug 12, 2024
3 changes: 3 additions & 0 deletions packages/gotrue/lib/src/constants.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ class Constants {

/// A token refresh will be attempted this many ticks before the current session expires.
static const autoRefreshTickThreshold = 3;

/// The name of the header that contains API version.
static const apiVersionHeaderName = 'x-supabase-api-version';
}

enum AuthChangeEvent {
Expand Down
59 changes: 50 additions & 9 deletions packages/gotrue/lib/src/fetch.dart
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import 'dart:convert';

import 'package:collection/collection.dart';
import 'package:gotrue/src/constants.dart';
import 'package:gotrue/src/types/api_version.dart';
import 'package:gotrue/src/types/auth_exception.dart';
import 'package:gotrue/src/types/error_code.dart';
import 'package:gotrue/src/types/fetch_options.dart';
import 'package:http/http.dart';

Expand All @@ -28,6 +31,16 @@ class GotrueFetch {
return error.toString();
}

String? _getErrorCode(dynamic error, String key) {
if (error is Map) {
final dynamic errorCode = error[key];
if (errorCode is String) {
return errorCode;
}
bdlukaa marked this conversation as resolved.
Show resolved Hide resolved
}
return null;
}

AuthException _handleError(dynamic error) {
if (error is! Response) {
throw AuthRetryableFetchException(message: error.toString());
Expand All @@ -50,24 +63,46 @@ class GotrueFetch {
message: error.toString(), originalError: error);
}

// Check if weak password reasons only contain strings
if (data is Map &&
data['weak_password'] is Map &&
data['weak_password']['reasons'] is List &&
(data['weak_password']['reasons'] as List).isNotEmpty &&
(data['weak_password']['reasons'] as List)
.whereNot((element) => element is String)
.isEmpty) {
String? errorCode;

final ApiVersion? version = ApiVersion.fromResponse(error);

if (version?.isSameOrAfter(ApiVersions.v20240101) ?? false) {
errorCode = _getErrorCode(data, 'code');
} else {
errorCode = _getErrorCode(data, 'error_code');
}

if (errorCode == null) {
// Legacy support for weak password errors, when there were no error codes
// Check if weak password reasons only contain strings
if (data is Map &&
data['weak_password'] is Map &&
data['weak_password']['reasons'] is List &&
(data['weak_password']['reasons'] as List).isNotEmpty &&
(data['weak_password']['reasons'] as List)
.whereNot((element) => element is String)
.isEmpty) {
throw AuthWeakPasswordException(
message: _getErrorMessage(data),
statusCode: error.statusCode.toString(),
errorCode: ErrorCode.weakPassword.code,
reasons: List<String>.from(data['weak_password']['reasons']),
);
}
} else if (errorCode == ErrorCode.weakPassword.code) {
throw AuthWeakPasswordException(
message: _getErrorMessage(data),
statusCode: error.statusCode.toString(),
reasons: List<String>.from(data['weak_password']['reasons']),
errorCode: errorCode,
reasons: List<String>.from(data['weak_password']?['reasons'] ?? []),
);
}

throw AuthApiException(
_getErrorMessage(data),
statusCode: error.statusCode.toString(),
errorCode: errorCode,
);
}

Expand All @@ -77,6 +112,12 @@ class GotrueFetch {
GotrueRequestOptions? options,
}) async {
final headers = options?.headers ?? {};

// Set the API version header if not already set
if (!headers.containsKey(Constants.apiVersionHeaderName)) {
headers[Constants.apiVersionHeaderName] = ApiVersions.v20240101.asString;
}

if (options?.jwt != null) {
headers['Authorization'] = 'Bearer ${options!.jwt}';
}
Expand Down
17 changes: 11 additions & 6 deletions packages/gotrue/lib/src/gotrue_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -607,7 +607,7 @@ class GoTrueClient {
/// If the current session's refresh token is invalid, an error will be thrown.
Future<AuthResponse> refreshSession([String? refreshToken]) async {
if (currentSession?.accessToken == null) {
throw AuthException('Not logged in.');
throw AuthSessionMissingException();
}

final currentSessionRefreshToken =
Expand All @@ -626,7 +626,7 @@ class GoTrueClient {
Future<void> reauthenticate() async {
final session = currentSession;
if (session == null) {
throw AuthException('Not logged in.');
throw AuthSessionMissingException();
}

final options =
Expand Down Expand Up @@ -691,7 +691,7 @@ class GoTrueClient {
/// Gets the current user details from current session or custom [jwt]
Future<UserResponse> getUser([String? jwt]) async {
if (jwt == null && currentSession?.accessToken == null) {
throw AuthException('Cannot get user: no current session.');
throw AuthSessionMissingException();
}
final options = GotrueRequestOptions(
headers: _headers,
Expand All @@ -712,7 +712,7 @@ class GoTrueClient {
}) async {
final accessToken = currentSession?.accessToken;
if (accessToken == null) {
throw AuthException('Not logged in.');
throw AuthSessionMissingException();
}

final body = attributes.toJson();
Expand All @@ -736,7 +736,7 @@ class GoTrueClient {
/// Sets the session data from refresh_token and returns the current session.
Future<AuthResponse> setSession(String refreshToken) async {
if (refreshToken.isEmpty) {
throw AuthException('No current session.');
throw AuthSessionMissingException('Refresh token cannot be empty');
}
return await _callRefreshToken(refreshToken);
}
Expand All @@ -757,8 +757,13 @@ class GoTrueClient {

final errorDescription = url.queryParameters['error_description'];
final errorCode = url.queryParameters['error_code'];
final error = url.queryParameters['error'];
if (errorDescription != null) {
throw AuthException(errorDescription, statusCode: errorCode);
throw AuthException(
errorDescription,
statusCode: errorCode,
errorCode: error,
);
}

if (_flowType == AuthFlowType.pkce) {
Expand Down
45 changes: 45 additions & 0 deletions packages/gotrue/lib/src/types/api_version.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import 'package:gotrue/src/constants.dart';
import 'package:http/http.dart';

// Parses the API version which is 2YYY-MM-DD. */
const String _apiVersionRegex =
r'^2[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|1[0-9]|2[0-9]|3[0-1])';

/// Represents the API versions supported by the package.
class ApiVersions {
const ApiVersions._();

static ApiVersion v20240101 = ApiVersion(DateTime(2024, 1, 1));
}

/// Represents the API version specified by a [date] in the format YYYY-MM-DD.
class ApiVersion {
ApiVersion(this.date);

/// Parses the API version from the string date.
static ApiVersion? fromString(String version) {
if (!RegExp(_apiVersionRegex).hasMatch(version)) {
return null;
}

final DateTime? date = DateTime.tryParse(version);
if (date == null) return null;
return ApiVersion(date);
}

/// Parses the API version from the response headers.
static ApiVersion? fromResponse(Response response) {
final String? version = response.headers[Constants.apiVersionHeaderName];
return version != null ? fromString(version) : null;
}

final DateTime date;

/// Return only the date part of the DateTime.
String get asString => date.toIso8601String().split('T').first;

/// Returns true if this version is the same or after [other].
bool isSameOrAfter(ApiVersion other) {
return date.isAfter(other.date) || date == other.date;
}
}
34 changes: 26 additions & 8 deletions packages/gotrue/lib/src/types/auth_exception.dart
Original file line number Diff line number Diff line change
@@ -1,44 +1,61 @@
import 'package:gotrue/src/types/error_code.dart';

class AuthException implements Exception {
/// Human readable error message associated with the error.
final String message;

/// HTTP status code that caused the error.
final String? statusCode;

const AuthException(this.message, {this.statusCode});
/// Error code associated with the error. Most errors coming from
/// HTTP responses will have a code, though some errors that occur
/// before a response is received will not have one present.
final String? errorCode;

const AuthException(this.message, {this.statusCode, this.errorCode});

@override
String toString() =>
'AuthException(message: $message, statusCode: $statusCode)';
'AuthException(message: $message, statusCode: $statusCode, errorCode: $errorCode)';

@override
bool operator ==(Object other) {
if (identical(this, other)) return true;

return other is AuthException &&
other.message == message &&
other.statusCode == statusCode;
other.statusCode == statusCode &&
other.errorCode == errorCode;
}

@override
int get hashCode => message.hashCode ^ statusCode.hashCode;
int get hashCode =>
message.hashCode ^ statusCode.hashCode ^ errorCode.hashCode;
}

class AuthPKCEGrantCodeExchangeError extends AuthException {
AuthPKCEGrantCodeExchangeError(super.message);
}

class AuthSessionMissingException extends AuthException {
AuthSessionMissingException()
: super('Auth session missing!', statusCode: '400');
AuthSessionMissingException([String? message])
: super(
message ?? 'Auth session missing!',
statusCode: '400',
errorCode: ErrorCode.sessionNotFound.code,
);
}

class AuthRetryableFetchException extends AuthException {
AuthRetryableFetchException({
String message = 'AuthRetryableFetchException',
super.statusCode,
super.errorCode,
}) : super(message);
}

class AuthApiException extends AuthException {
AuthApiException(super.message, {super.statusCode});
AuthApiException(super.message, {super.statusCode, super.errorCode});
}

class AuthUnknownException extends AuthException {
Expand All @@ -55,5 +72,6 @@ class AuthWeakPasswordException extends AuthException {
required String message,
required String statusCode,
required this.reasons,
}) : super(message, statusCode: statusCode);
String? errorCode,
}) : super(message, statusCode: statusCode, errorCode: errorCode);
}
90 changes: 90 additions & 0 deletions packages/gotrue/lib/src/types/error_code.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// All error codes from the Supabase Auth API. The whole list can be found here:
// https://github.com/supabase/auth/blob/master/internal/api/errorcodes.go
import 'package:collection/collection.dart';

enum ErrorCode {
unknown('unknown'),
unexpectedFailure('unexpected_failure'),
validationFailed('validation_failed'),
badJson('bad_json'),
emailExists('email_exists'),
phoneExists('phone_exists'),
badJwt('bad_jwt'),
notAdmin('not_admin'),
noAuthorization('no_authorization'),
userNotFound('user_not_found'),
sessionNotFound('session_not_found'),
flowStateNotFound('flow_state_not_found'),
flowStateExpired('flow_state_expired'),
signupDisabled('signup_disabled'),
userBanned('user_banned'),
providerEmailNeedsVerification('provider_email_needs_verification'),
inviteNotFound('invite_not_found'),
badOauthState('bad_oauth_state'),
badOauthCallback('bad_oauth_callback'),
oauthProviderNotSupported('oauth_provider_not_supported'),
unexpectedAudience('unexpected_audience'),
singleIdentityNotDeletable('single_identity_not_deletable'),
emailConflictIdentityNotDeletable('email_conflict_identity_not_deletable'),
identityAlreadyExists('identity_already_exists'),
emailProviderDisabled('email_provider_disabled'),
phoneProviderDisabled('phone_provider_disabled'),
tooManyEnrolledMfaFactors('too_many_enrolled_mfa_factors'),
mfaFactorNameConflict('mfa_factor_name_conflict'),
mfaFactorNotFound('mfa_factor_not_found'),
mfaIpAddressMismatch('mfa_ip_address_mismatch'),
mfaChallengeExpired('mfa_challenge_expired'),
mfaVerificationFailed('mfa_verification_failed'),
mfaVerificationRejected('mfa_verification_rejected'),
insufficientAal('insufficient_aal'),
captchaFailed('captcha_failed'),
samlProviderDisabled('saml_provider_disabled'),
manualLinkingDisabled('manual_linking_disabled'),
smsSendFailed('sms_send_failed'),
emailNotConfirmed('email_not_confirmed'),
phoneNotConfirmed('phone_not_confirmed'),
samlRelayStateNotFound('saml_relay_state_not_found'),
samlRelayStateExpired('saml_relay_state_expired'),
samlIdpNotFound('saml_idp_not_found'),
samlAssertionNoUserId('saml_assertion_no_user_id'),
samlAssertionNoEmail('saml_assertion_no_email'),
userAlreadyExists('user_already_exists'),
ssoProviderNotFound('sso_provider_not_found'),
samlMetadataFetchFailed('saml_metadata_fetch_failed'),
samlIdpAlreadyExists('saml_idp_already_exists'),
ssoDomainAlreadyExists('sso_domain_already_exists'),
samlEntityIdMismatch('saml_entity_id_mismatch'),
conflict('conflict'),
providerDisabled('provider_disabled'),
userSsoManaged('user_sso_managed'),
reauthenticationNeeded('reauthentication_needed'),
samePassword('same_password'),
reauthenticationNotValid('reauthentication_not_valid'),
otpExpired('otp_expired'),
otpDisabled('otp_disabled'),
identityNotFound('identity_not_found'),
weakPassword('weak_password'),
overRequestRateLimit('over_request_rate_limit'),
overEmailSendRateLimit('over_email_send_rate_limit'),
overSmsSendRateLimit('over_sms_send_rate_limit'),
badCodeVerifier('bad_code_verifier'),
anonymousProviderDisabled('anonymous_provider_disabled'),
hookTimeout('hook_timeout'),
hookTimeoutAfterRetry('hook_timeout_after_retry'),
hookPayloadOverSizeLimit('hook_payload_over_size_limit'),
hookPayloadUnknownSize('hook_payload_unknown_size'),
requestTimeout('request_timeout'),
mfaPhoneEnrollDisabled('mfa_phone_enroll_not_enabled'),
mfaPhoneVerifyDisabled('mfa_phone_verify_not_enabled'),
mfaTotpEnrollDisabled('mfa_totp_enroll_not_enabled'),
mfaTotpVerifyDisabled('mfa_totp_verify_not_enabled');
dshukertjr marked this conversation as resolved.
Show resolved Hide resolved

final String code;
const ErrorCode(this.code);

static ErrorCode? fromCode(String code) {
return ErrorCode.values.firstWhereOrNull(
(value) => value.code == code,
);
}
}
Loading
Loading