Authentication API
The OpenLift Authentication API provides comprehensive user authentication using JWT tokens with RSA-256 signing, HMAC request verification, and secure session management. This system is designed for high-security fitness applications with proper token management and request integrity verification.
🔐 Authentication Architecture
Core Security Features
- JWT Tokens: RSA-256 signed access tokens (15 min) + refresh tokens (7 days)
- HMAC Request Signing: Production request integrity verification
- Stateful Refresh Tokens: Stored in MongoDB for proper session management
- Role-Based Access: User role management and permissions
- Password Security: Bcrypt hashing with configurable salt rounds
Token Structure
Access Token Payload
{
"id": "user_object_id",
"email": "user@example.com",
"role": "USER",
"iat": 1640995200,
"exp": 1640996100,
"iss": "openlift-api",
"aud": "openlift-client"
}
Refresh Token Payload
{
"id": "user_object_id",
"jti": "unique_token_id",
"iat": 1640995200,
"exp": 1641600000,
"iss": "openlift-api",
"aud": "openlift-client"
}
🌐 REST Endpoints
Authentication Flow
- User Login
- User Registration
- Token Refresh
- User Logout
POST /auth/login
{
"email": "user@example.com",
"password": "userPassword123"
}
{
"accessToken": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"refreshToken": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"user": {
"id": "60d5ecb54f3d4d2e8c8e4b5a",
"email": "user@example.com",
"username": "fitnessuser",
"role": "USER",
"enabled": true,
"verified": true
}
}
Validations:
- Email must be valid format
- Password required (strength validated on registration)
- Account must be enabled and verified
Error Responses:
400- Invalid email/password format401- Invalid credentials403- Account disabled or unverified429- Too many login attempts
POST /auth/signup
{
"email": "newuser@example.com",
"password": "strongPassword123!",
"username": "newfitnessuser"
}
{
"user": {
"id": "60d5ecb54f3d4d2e8c8e4b5b",
"email": "newuser@example.com",
"username": "newfitnessuser",
"role": "USER",
"enabled": true,
"verified": false,
"createdAt": "2023-12-01T10:00:00.000Z"
},
"message": "Registration successful. Please check your email for verification."
}
Validations:
- Email must be unique and valid
- Username must be unique (3-30 characters)
- Password strength requirements enforced
- All fields required
Error Responses:
400- Validation errors (weak password, invalid email, etc.)409- Email or username already exists
POST /auth/refresh
{
"refreshToken": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
}
{
"accessToken": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"expiresIn": 900
}
Process:
- Validates refresh token signature and expiration
- Checks token exists in database and is not revoked
- Issues new access token with updated expiration
- Original refresh token remains valid until its expiration
Error Responses:
401- Invalid or expired refresh token404- Refresh token not found in database403- Token revoked or user disabled
POST /auth/logout
{
"refreshToken": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
}
No Content
Process:
- Invalidates the provided refresh token
- Removes token from database
- Client should discard both access and refresh tokens
Headers Required:
Authorization: Bearer <access_token>
Profile Management
- Get Profile
- Update Profile
- Delete Account
GET /auth/profile
{
"id": "60d5ecb54f3d4d2e8c8e4b5a",
"email": "user@example.com",
"username": "fitnessuser",
"firstName": "John",
"lastName": "Doe",
"profileImageFileKey": "profile_images/user123.jpg",
"country": "United States",
"city": "New York",
"bio": "Passionate about fitness and healthy living",
"role": "USER",
"enabled": true,
"verified": true,
"createdAt": "2023-01-01T10:00:00.000Z",
"lastLogin": "2023-12-01T09:30:00.000Z",
"profile": {
"age": 28,
"gender": "MALE",
"goal": "muscle_gain",
"height": 180,
"heightUnit": "CM",
"weight": 75.5,
"weightUnit": "KG",
"muscleFocus": ["chest", "shoulders", "legs"],
"frequency": 4,
"workoutSplit": "push_pull_legs",
"experience": "intermediate"
}
}
Authorization: Bearer token required
PUT /auth/profile
{
"firstName": "John",
"lastName": "Doe Updated",
"bio": "Updated bio text",
"country": "Canada",
"city": "Toronto",
"profile": {
"age": 29,
"weight": 76.0,
"goal": "strength_gain",
"frequency": 5
}
}
{
"id": "60d5ecb54f3d4d2e8c8e4b5a",
"email": "user@example.com",
"username": "fitnessuser",
"firstName": "John",
"lastName": "Doe Updated",
"bio": "Updated bio text",
"country": "Canada",
"city": "Toronto",
// ... rest of profile data
}
Validations:
- Profile fields are optional
- Age must be between 13-120
- Weight/height must be positive numbers
- Valid enum values for gender, units, etc.
DELETE /auth/account
{
"message": "Account deletion initiated successfully",
"scheduledDeletionDate": "2023-12-08T10:00:00.000Z"
}
Process:
- Marks account for deletion (7-day grace period)
- Invalidates all refresh tokens
- User can still log in during grace period to cancel deletion
- After 7 days, account and all associated data is permanently deleted
Authorization: Bearer token required
🔧 GraphQL Operations
User Profile Management
- Me Query
- Profile Update Mutations
- Onboarding Mutations
query Me {
me {
id
email
username
firstName
lastName
profileImageFileKey
country
city
bio
achievements
workoutsCompleted
improvements
role
enabled
verified
createdAt
lastLogin
profile {
onboardingStatus
onboardingCompletionDate
age
gender
goal
height
heightUnit
weight
weightUnit
muscleFocus
frequency
workoutSplit
experience
createdAt
updatedAt
}
}
}
Response:
{
"data": {
"me": {
"id": "60d5ecb54f3d4d2e8c8e4b5a",
"email": "user@example.com",
"username": "fitnessuser",
// ... complete profile data
}
}
}
Authorization: JWT token required via Authorization header
Update Basic Profile
mutation UpdateMyProfile($data: UpdateMyProfileInput!) {
updateMyProfile(data: $data) {
id
firstName
lastName
bio
country
city
profileImageFileKey
updatedAt
}
}
{
"data": {
"firstName": "John",
"lastName": "Smith",
"bio": "Fitness enthusiast and runner",
"country": "United States",
"city": "Los Angeles"
}
}
Update Profile Details
mutation UpdateMyUserProfileDetails($data: UpdateMyUserProfileDetailsInput!) {
updateMyUserProfileDetails(data: $data) {
id
profile {
age
gender
goal
height
heightUnit
weight
weightUnit
frequency
experience
updatedAt
}
}
}
{
"data": {
"age": 30,
"weight": 75.5,
"goal": "muscle_gain",
"frequency": 4,
"experience": "advanced"
}
}
Change Password
mutation ChangePassword($input: ChangePasswordInput!) {
changePassword(input: $input)
}
{
"input": {
"currentPassword": "oldPassword123",
"newPassword": "newStrongPassword456!"
}
}
Returns: Boolean - true on success
Complete Onboarding
mutation CompleteOnboarding($input: OnboardingDataInput!) {
completeOnboarding(input: $input) {
id
profile {
onboardingStatus
onboardingCompletionDate
age
gender
goal
height
heightUnit
weight
weightUnit
frequency
workoutSplit
experience
}
}
}
{
"input": {
"age": 25,
"gender": "FEMALE",
"goal": "weight_loss",
"height": 165,
"heightUnit": "CM",
"weight": 65,
"weightUnit": "KG",
"frequency": 3,
"workoutSplit": "full_body",
"experience": "beginner"
}
}
Update Onboarding Data
mutation UpdateOnboardingData($input: UpdateOnboardingDataInput!) {
updateOnboardingData(input: $input) {
id
profile {
onboardingStatus
age
goal
frequency
updatedAt
}
}
}
Purpose: Allows partial updates during step-by-step onboarding without marking as complete
🔒 HMAC Request Signing
Production Security
In production, all requests require HMAC signature verification for integrity protection.
Required Headers
Authorization: Bearer <jwt_access_token>
X-User-ID: <user_object_id>
X-Timestamp: <unix_timestamp>
X-Signature: <hmac_signature>
Signature Generation
function generateHMACSignature(
secret: string,
method: string,
path: string,
timestamp: number,
body?: string,
userId?: string
): string {
const payload = `${method.toUpperCase()}|${path}|${timestamp}|${body || ''}|${userId || ''}`;
return crypto
.createHmac('sha256', secret)
.update(payload, 'utf8')
.digest('hex');
}
Example Implementation
- Flutter Implementation
- JavaScript/Node.js
import 'dart:convert';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
class HMACService {
static const String _secret = 'your-hmac-secret';
static String generateSignature({
required String method,
required String path,
required int timestamp,
String? body,
String? userId,
}) {
final payload = '${method.toUpperCase()}|$path|$timestamp|${body ?? ''}|${userId ?? ''}';
final key = utf8.encode(_secret);
final bytes = utf8.encode(payload);
final hmacSha256 = Hmac(sha256, key);
final digest = hmacSha256.convert(bytes);
return digest.toString();
}
static Map<String, String> getSignedHeaders({
required String method,
required String path,
required String accessToken,
required String userId,
String? body,
}) {
final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final signature = generateSignature(
method: method,
path: path,
timestamp: timestamp,
body: body,
userId: userId,
);
return {
'Authorization': 'Bearer $accessToken',
'X-User-ID': userId,
'X-Timestamp': timestamp.toString(),
'X-Signature': signature,
'Content-Type': 'application/json',
};
}
}
const crypto = require('crypto');
class HMACService {
static secret = process.env.HMAC_SECRET;
static generateSignature({
method,
path,
timestamp,
body = '',
userId = ''
}) {
const payload = `${method.toUpperCase()}|${path}|${timestamp}|${body}|${userId}`;
return crypto
.createHmac('sha256', this.secret)
.update(payload, 'utf8')
.digest('hex');
}
static getSignedHeaders({
method,
path,
accessToken,
userId,
body = null
}) {
const timestamp = Math.floor(Date.now() / 1000);
const signature = this.generateSignature({
method,
path,
timestamp,
body: body ? JSON.stringify(body) : '',
userId
});
return {
'Authorization': `Bearer ${accessToken}`,
'X-User-ID': userId,
'X-Timestamp': timestamp.toString(),
'X-Signature': signature,
'Content-Type': 'application/json'
};
}
}
Development Mode
In development environments, HMAC verification is disabled for easier testing. Only JWT authentication is required.
🛡️ Security Best Practices
Token Management
Flutter Implementation
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class TokenManager {
static const _storage = FlutterSecureStorage();
static const String _accessTokenKey = 'access_token';
static const String _refreshTokenKey = 'refresh_token';
static const String _userIdKey = 'user_id';
static Future<void> saveTokens({
required String accessToken,
required String refreshToken,
required String userId,
}) async {
await _storage.write(key: _accessTokenKey, value: accessToken);
await _storage.write(key: _refreshTokenKey, value: refreshToken);
await _storage.write(key: _userIdKey, value: userId);
}
static Future<String?> getAccessToken() async {
return await _storage.read(key: _accessTokenKey);
}
static Future<String?> getRefreshToken() async {
return await _storage.read(key: _refreshTokenKey);
}
static Future<String?> getUserId() async {
return await _storage.read(key: _userIdKey);
}
static Future<void> clearTokens() async {
await _storage.delete(key: _accessTokenKey);
await _storage.delete(key: _refreshTokenKey);
await _storage.delete(key: _userIdKey);
}
}
Automatic Token Refresh
import 'package:dio/dio.dart';
class AuthInterceptor extends Interceptor {
final Dio _dio;
AuthInterceptor(this._dio);
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
final accessToken = await TokenManager.getAccessToken();
if (accessToken != null) {
options.headers['Authorization'] = 'Bearer $accessToken';
}
handler.next(options);
}
@override
void onError(DioError err, ErrorInterceptorHandler handler) async {
if (err.response?.statusCode == 401) {
// Attempt token refresh
try {
final refreshToken = await TokenManager.getRefreshToken();
if (refreshToken != null) {
final newTokens = await _refreshTokens(refreshToken);
// Retry original request with new token
final opts = err.requestOptions;
opts.headers['Authorization'] = 'Bearer ${newTokens.accessToken}';
final clonedRequest = await _dio.request(
opts.path,
options: Options(
method: opts.method,
headers: opts.headers,
),
data: opts.data,
queryParameters: opts.queryParameters,
);
return handler.resolve(clonedRequest);
}
} catch (e) {
// Refresh failed - redirect to login
await TokenManager.clearTokens();
// Navigate to login screen
}
}
handler.next(err);
}
Future<TokenResponse> _refreshTokens(String refreshToken) async {
final response = await _dio.post('/auth/refresh', data: {
'refreshToken': refreshToken,
});
final newAccessToken = response.data['accessToken'];
final userId = await TokenManager.getUserId();
await TokenManager.saveTokens(
accessToken: newAccessToken,
refreshToken: refreshToken, // Keep same refresh token
userId: userId!,
);
return TokenResponse(accessToken: newAccessToken);
}
}
class TokenResponse {
final String accessToken;
TokenResponse({required this.accessToken});
}
Error Handling
Common Error Scenarios
enum AuthErrorType {
invalidCredentials,
accountDisabled,
accountNotVerified,
tokenExpired,
tokenInvalid,
networkError,
rateLimited,
serverError,
}
class AuthException implements Exception {
final String message;
final AuthErrorType type;
final int? statusCode;
AuthException({
required this.message,
required this.type,
this.statusCode,
});
}
class AuthService {
static Future<LoginResponse> login({
required String email,
required String password,
}) async {
try {
final response = await dio.post('/auth/login', data: {
'email': email,
'password': password,
});
return LoginResponse.fromJson(response.data);
} on DioError catch (e) {
switch (e.response?.statusCode) {
case 400:
throw AuthException(
message: 'Invalid email or password format',
type: AuthErrorType.invalidCredentials,
statusCode: 400,
);
case 401:
throw AuthException(
message: 'Invalid credentials',
type: AuthErrorType.invalidCredentials,
statusCode: 401,
);
case 403:
final errorData = e.response?.data;
if (errorData['code'] == 'ACCOUNT_DISABLED') {
throw AuthException(
message: 'Your account has been disabled',
type: AuthErrorType.accountDisabled,
statusCode: 403,
);
} else {
throw AuthException(
message: 'Please verify your email address',
type: AuthErrorType.accountNotVerified,
statusCode: 403,
);
}
case 429:
throw AuthException(
message: 'Too many login attempts. Please try again later.',
type: AuthErrorType.rateLimited,
statusCode: 429,
);
default:
throw AuthException(
message: 'Network error occurred',
type: AuthErrorType.networkError,
statusCode: e.response?.statusCode,
);
}
} catch (e) {
throw AuthException(
message: 'An unexpected error occurred',
type: AuthErrorType.serverError,
);
}
}
}
📱 Flutter Integration Examples
Complete Authentication Flow
- Login Screen
- Authentication Provider
- App Setup
import 'package:flutter/material.dart';
class LoginScreen extends StatefulWidget {
@override
_LoginScreenState createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
bool _isLoading = false;
String? _errorMessage;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Login')),
body: Padding(
padding: EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
children: [
TextFormField(
controller: _emailController,
decoration: InputDecoration(
labelText: 'Email',
errorText: _errorMessage,
),
validator: (value) {
if (value?.isEmpty ?? true) return 'Email is required';
if (!value!.contains('@')) return 'Invalid email';
return null;
},
),
SizedBox(height: 16),
TextFormField(
controller: _passwordController,
decoration: InputDecoration(labelText: 'Password'),
obscureText: true,
validator: (value) {
if (value?.isEmpty ?? true) return 'Password is required';
return null;
},
),
SizedBox(height: 24),
ElevatedButton(
onPressed: _isLoading ? null : _login,
child: _isLoading
? CircularProgressIndicator()
: Text('Login'),
),
TextButton(
onPressed: () => Navigator.pushNamed(context, '/register'),
child: Text('Don\'t have an account? Sign up'),
),
],
),
),
),
);
}
Future<void> _login() async {
if (!_formKey.currentState!.validate()) return;
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final response = await AuthService.login(
email: _emailController.text.trim(),
password: _passwordController.text,
);
// Save tokens
await TokenManager.saveTokens(
accessToken: response.accessToken,
refreshToken: response.refreshToken,
userId: response.user.id,
);
// Navigate to main app
Navigator.pushReplacementNamed(context, '/home');
} on AuthException catch (e) {
setState(() {
_errorMessage = e.message;
});
} finally {
setState(() {
_isLoading = false;
});
}
}
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
}
import 'package:flutter/material.dart';
class AuthProvider extends ChangeNotifier {
User? _currentUser;
bool _isLoading = false;
bool _isAuthenticated = false;
User? get currentUser => _currentUser;
bool get isLoading => _isLoading;
bool get isAuthenticated => _isAuthenticated;
Future<void> checkAuthStatus() async {
_setLoading(true);
try {
final accessToken = await TokenManager.getAccessToken();
if (accessToken != null) {
// Validate token and get user profile
final user = await AuthService.getCurrentUser();
_setUser(user);
}
} catch (e) {
// Token invalid, clear storage
await logout();
} finally {
_setLoading(false);
}
}
Future<void> login({
required String email,
required String password,
}) async {
_setLoading(true);
try {
final response = await AuthService.login(
email: email,
password: password,
);
await TokenManager.saveTokens(
accessToken: response.accessToken,
refreshToken: response.refreshToken,
userId: response.user.id,
);
_setUser(response.user);
} finally {
_setLoading(false);
}
}
Future<void> register({
required String email,
required String password,
required String username,
}) async {
_setLoading(true);
try {
final response = await AuthService.register(
email: email,
password: password,
username: username,
);
// Registration successful but user needs to verify email
// Don't set as authenticated yet
} finally {
_setLoading(false);
}
}
Future<void> logout() async {
try {
final refreshToken = await TokenManager.getRefreshToken();
if (refreshToken != null) {
await AuthService.logout(refreshToken);
}
} catch (e) {
// Network error during logout - still clear local tokens
} finally {
await TokenManager.clearTokens();
_setUser(null);
}
}
Future<void> updateProfile(UpdateProfileData data) async {
if (_currentUser == null) return;
try {
final updatedUser = await AuthService.updateProfile(data);
_setUser(updatedUser);
} catch (e) {
rethrow;
}
}
void _setUser(User? user) {
_currentUser = user;
_isAuthenticated = user != null;
notifyListeners();
}
void _setLoading(bool loading) {
_isLoading = loading;
notifyListeners();
}
}
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:dio/dio.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize HTTP client with interceptors
final dio = Dio();
dio.interceptors.add(AuthInterceptor(dio));
runApp(MyApp(dio: dio));
}
class MyApp extends StatelessWidget {
final Dio dio;
MyApp({required this.dio});
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => AuthProvider()),
Provider<Dio>.value(value: dio),
],
child: MaterialApp(
title: 'OpenLift',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: AuthWrapper(),
routes: {
'/login': (context) => LoginScreen(),
'/register': (context) => RegisterScreen(),
'/home': (context) => HomeScreen(),
'/onboarding': (context) => OnboardingScreen(),
},
),
);
}
}
class AuthWrapper extends StatefulWidget {
@override
_AuthWrapperState createState() => _AuthWrapperState();
}
class _AuthWrapperState extends State<AuthWrapper> {
@override
void initState() {
super.initState();
// Check authentication status on app start
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<AuthProvider>().checkAuthStatus();
});
}
@override
Widget build(BuildContext context) {
return Consumer<AuthProvider>(
builder: (context, authProvider, child) {
if (authProvider.isLoading) {
return Scaffold(
body: Center(
child: CircularProgressIndicator(),
),
);
}
if (!authProvider.isAuthenticated) {
return LoginScreen();
}
// Check if user completed onboarding
if (authProvider.currentUser?.profile?.onboardingStatus != 'COMPLETED') {
return OnboardingScreen();
}
return HomeScreen();
},
);
}
}
🔧 Testing
Unit Test Examples
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:dio/dio.dart';
void main() {
group('AuthService', () {
late MockDio mockDio;
late AuthService authService;
setUp(() {
mockDio = MockDio();
authService = AuthService(mockDio);
});
test('should return login response on successful login', () async {
// Arrange
final responseData = {
'accessToken': 'mock_access_token',
'refreshToken': 'mock_refresh_token',
'user': {
'id': 'user123',
'email': 'test@example.com',
'username': 'testuser',
'role': 'USER',
}
};
when(mockDio.post('/auth/login', data: anyNamed('data')))
.thenAnswer((_) async => Response(
data: responseData,
statusCode: 200,
requestOptions: RequestOptions(path: '/auth/login'),
));
// Act
final result = await authService.login(
email: 'test@example.com',
password: 'password123',
);
// Assert
expect(result.accessToken, equals('mock_access_token'));
expect(result.user.id, equals('user123'));
verify(mockDio.post('/auth/login', data: {
'email': 'test@example.com',
'password': 'password123',
})).called(1);
});
test('should throw AuthException on invalid credentials', () async {
// Arrange
when(mockDio.post('/auth/login', data: anyNamed('data')))
.thenThrow(DioError(
response: Response(
statusCode: 401,
data: {'message': 'Invalid credentials'},
requestOptions: RequestOptions(path: '/auth/login'),
),
requestOptions: RequestOptions(path: '/auth/login'),
));
// Act & Assert
expect(
() => authService.login(
email: 'test@example.com',
password: 'wrongpassword',
),
throwsA(isA<AuthException>()
.having((e) => e.type, 'type', AuthErrorType.invalidCredentials)
.having((e) => e.statusCode, 'statusCode', 401)),
);
});
});
}
📚 Environment Configuration
JWT Configuration
# JWT Configuration
JWT_ISSUER=openlift-api
JWT_AUDIENCE=openlift-client
JWT_ACCESS_TOKEN_EXPIRES_IN=15m
JWT_REFRESH_TOKEN_EXPIRES_IN=7d
JWT_PRIVATE_KEY_PATH=./private.pem
JWT_PUBLIC_KEY_PATH=./public.pem
# HMAC Configuration
HMAC_SECRET=your-256-bit-secret-key-here
HMAC_TIMESTAMP_TOLERANCE=300
# Database
MONGO_DATABASE_URL=mongodb://localhost:27017/openlift
# Redis (for rate limiting)
REDIS_URL=redis://localhost:6379
Key Generation
# Generate private key
openssl genrsa -out private.pem 2048
# Generate public key
openssl rsa -in private.pem -pubout -out public.pem
# Generate HMAC secret
openssl rand -hex 32
🚀 Migration Notes
- All user IDs are MongoDB ObjectId strings
- JWT tokens use RSA-256 signing (not HS256)
- HMAC verification is enforced in production environments
- Password hashing uses bcrypt with configurable salt rounds
- Refresh tokens are stateful (stored in database)
- Profile data includes nested UserProfile object
- Role-based access control ready for expansion
🆘 Support
For authentication-related questions:
- Check JWT token expiration and refresh logic
- Verify HMAC signature generation matches server requirements
- Ensure proper error handling for all auth scenarios
- Contact backend team for role and permission questions
Ready to implement authentication? Check out the User Management API for profile and user data operations.