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
Empty file added .git-blame-ignore-revs
Empty file.
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,8 @@ build/
.dart_frog

# Test related files
coverage/
coverage/

# Environment files
.env
supabase_auth.json
27 changes: 27 additions & 0 deletions lib/server_exception.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import 'dart:io';

/// A general exception class used to handle error messages on the server side
class ServerException implements Exception {
/// Constructor
ServerException({
required this.errorMessage,
String? errorCode,
this.errorBody,
}) : errorCode = errorCode != null
? int.parse(errorCode)
: HttpStatus.internalServerError;

/// The error message received
final String errorMessage;

/// Error code of exception
final int errorCode;

/// An error body if available
final Map<String, dynamic>? errorBody;

@override
String toString() {
return 'ServerException -> $errorMessage';
}
}
213 changes: 213 additions & 0 deletions lib/src/auth/auth_repo.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import 'package:supabase/supabase.dart';
import 'package:touch_cut_server/server_exception.dart';

/// An exception class for the authentication repository
class AuthRepoException extends ServerException {
/// Constructor for the AuthRepoException class
AuthRepoException({
required String errorMessage,
super.errorCode,
super.errorBody,
}) : super(
errorMessage: 'AuthRepoException -> $errorMessage',
);
}

class AuthRepo {
/// Constructor
const AuthRepo({required this.supabaseClient});

/// The supabase client
final SupabaseClient supabaseClient;

/// Send OTP to user
Future<void> sendOtpToUser({
String? phoneNumber,
String? email,
Map<String, dynamic>? data,
}) async {
try {
_validateEmailAndPhoneNumber(
email: email,
phoneNumber: phoneNumber,
);
await supabaseClient.auth.signInWithOtp(
email: email,
phone: phoneNumber,
data: data,
);
} on AuthException catch (authException) {
print(authException);
throw AuthRepoException(
errorCode: authException.statusCode,
errorMessage: authException.toString(),
);
} catch (err) {
print(err);
throw AuthRepoException(
errorMessage: err.toString(),
);
}
}

/// Resend OTP to user
Future<void> resendOtpToUser({
String? phoneNumber,
String? email,
}) async {
try {
_validateEmailAndPhoneNumber(
email: email,
phoneNumber: phoneNumber,
);
await supabaseClient.auth.resend(
type: phoneNumber != null ? OtpType.sms : OtpType.email,
phone: phoneNumber,
email: email,
);
} on AuthException catch (authException) {
print(authException);
throw AuthRepoException(
errorCode: authException.statusCode,
errorMessage: authException.toString(),
);
} catch (err) {
print(err);
throw AuthRepoException(
errorMessage: err.toString(),
);
}
}

/// Verify the token and return the user
Future<User?> verifyAccessToken({
required String accessToken,
}) async {
try {
final userResponse = await supabaseClient.auth.getUser(accessToken);
print("Got the user $userResponse");
return userResponse.user;
} on AuthException catch (authException) {
print(authException);
// We will have a wrapper that sends a 401 status code if the user is null
return null;
} catch (err) {
throw AuthRepoException(
errorMessage: err.toString(),
);
}
}

/// Verifies the OTP supplied by the user, and returns
/// the access and refresh token, after creating a session
/// for the user
Future<Map<String, String>> verifyOTP({
required String token,
String? phoneNumber,
String? email,
}) async {
try {
_validateEmailAndPhoneNumber(
email: email,
phoneNumber: phoneNumber,
);
final authResponse = await supabaseClient.auth.verifyOTP(
type: phoneNumber != null ? OtpType.sms : OtpType.email,
token: token,
phone: phoneNumber,
email: email,
);
print('Hello $authResponse');
final session = authResponse.session!;
return {
'access_token': session.accessToken,
'refresh_token': session.refreshToken!,
};
} on AuthException catch (authException) {
print(authException);
throw AuthRepoException(
errorCode: authException.statusCode,
errorMessage: authException.toString(),
);
} catch (err) {
throw AuthRepoException(
errorMessage: err.toString(),
);
}
}

void _validateEmailAndPhoneNumber({
String? email,
String? phoneNumber,
}) {
if (phoneNumber == null && email == null) {
throw Exception('Phone number and email cannot be null');
}
if (phoneNumber != null && email != null) {
throw Exception('Only supply either email or phone number');
}
}

/// Refresh access and refresh token
Future<Map<String, String>> refreshAccessToken({
required String refreshToken,
}) async {
try {
final authResponse = await supabaseClient.auth.setSession(refreshToken);
final session = authResponse.session!;
return {
'access_token': session.accessToken,
'refresh_token': session.refreshToken!,
};
} on AuthException catch (authException) {
throw AuthRepoException(
errorCode: authException.statusCode,
errorMessage: authException.toString(),
);
} catch (err) {
throw AuthRepoException(
errorMessage: err.toString(),
);
}
}

/// This function checks if the supplied phone number from the customer is
/// in the DB, while trying to sign in. If not found, that means no customer
/// exits with that phone number, therefore, we will return an error asking
/// them to sign-up, if found, we will go ahead and send an SMS containing
/// an OTP.
/// IMPORTANT -> This is used to check customers, since customers sign in
/// using phone numbers.
Future<bool> customerPhoneNumberExists({required String phoneNumber}) async {
// Supabase DB saves the phone number without the + sign
var phoneNumberToCheck = phoneNumber;
if (phoneNumber.startsWith('+')) {
phoneNumberToCheck = phoneNumber.substring(1);
}
final data = await supabaseClient
.from('customers')
.select()
.eq('phone_number', phoneNumberToCheck);
if (data.isEmpty) {
print('$phoneNumber not found in the system');
return false;
}
print('$phoneNumber found in the system');
return true;
}

/// Same check for the customer phone number, but in here, we check
/// the email of the barbershop
/// IMPORTANT -> This is used to check barbershops, since they sign in
/// using emails.
Future<bool> barbershopEmailExists({required String email}) async {
final data =
await supabaseClient.from('barbershops').select().eq('email', email);
if (data.isEmpty) {
print('$email not found in the system');
return false;
}
print('$email found in the system');
return true;
}
}
44 changes: 44 additions & 0 deletions lib/src/file_storage.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import 'dart:convert';
import 'dart:io';

import 'package:supabase/supabase.dart';

class FileStorage implements GotrueAsyncStorage {
FileStorage(String path) : file = File(path);
final File file;

@override
Future<void> removeItem({required String key}) async {
final json = await _readJson();
json.remove(key);
await _writeJson(json);
}

@override
Future<String?> getItem({required String key}) async {
final json = await _readJson();
return json[key] as String?;
}

@override
Future<void> setItem({
required String key,
required String value,
}) async {
final json = await _readJson();
json[key] = value;
await _writeJson(json);
}

Future<Map<String, dynamic>> _readJson() async {
if (!await file.exists()) {
return {};
}
final contents = await file.readAsString();
return jsonDecode(contents) as Map<String, dynamic>;
}

Future<void> _writeJson(Map<String, dynamic> json) async {
await file.writeAsString(jsonEncode(json));
}
}
24 changes: 24 additions & 0 deletions main.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import 'dart:io';

import 'package:dart_frog/dart_frog.dart';
import 'package:dotenv/dotenv.dart';
import 'package:supabase/supabase.dart';
import 'package:touch_cut_server/src/auth/auth_repo.dart';
import 'package:touch_cut_server/src/file_storage.dart';

late AuthRepo authRepo;

Future<HttpServer> run(Handler handler, InternetAddress ip, int port) {
final storage = FileStorage('supabase_auth.json');
final env = DotEnv(includePlatformEnvironment: true)..load();
final supabaseClient = SupabaseClient(
env['SUPB_URL']!,
// ignore: lines_longer_than_80_chars
env['SUPB_SERVICE_ROLE']!, // This is only used in the server, not on the client side
authOptions: AuthClientOptions(
pkceAsyncStorage: storage,
),
);
authRepo = AuthRepo(supabaseClient: supabaseClient);
return serve(handler, ip, port);
}
3 changes: 3 additions & 0 deletions pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ environment:

dependencies:
dart_frog: ^1.0.0
dart_frog_auth: ^1.1.0
dotenv: ^4.2.0
supabase: ^2.1.1

dev_dependencies:
mocktail: ^1.0.0
Expand Down
17 changes: 17 additions & 0 deletions routes/_middleware.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import 'package:dart_frog/dart_frog.dart';
import 'package:touch_cut_server/src/auth/auth_repo.dart';

import '../main.dart';

Handler middleware(Handler handler) {
return handler
.use(requestLogger())

/// AuthRepo will be used as a middleware:
/// 1. Send the OTP to the user.
/// 2. Validate the OTP supplied by the user.
/// 3. Authenticate every inbound request when
/// needed (whether the access token supplied is valid).
/// 4. To refresh the access token.
.use(provider<AuthRepo>((_) => authRepo));
}
Loading