6/8 - Public Beta (Discord)
|See Changelog
Skip to main content

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

POST /auth/login

Request
{
"email": "user@example.com",
"password": "userPassword123"
}
Response (200)
{
"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 format
  • 401 - Invalid credentials
  • 403 - Account disabled or unverified
  • 429 - Too many login attempts

Profile Management

GET /auth/profile

Response (200)
{
"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

🔧 GraphQL Operations

User Profile Management

Query
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

🔒 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

HMAC Signature Algorithm
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

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',
};
}
}

Development Mode

In development environments, HMAC verification is disabled for easier testing. Only JWT authentication is required.

🛡️ Security Best Practices

Token Management

Flutter Implementation

Token Storage
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

HTTP Interceptor
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

Authentication Error Handling
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.dart
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();
}
}

🔧 Testing

Unit Test Examples

auth_service_test.dart
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

.env
# 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 RSA Keys
# 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:

  1. Check JWT token expiration and refresh logic
  2. Verify HMAC signature generation matches server requirements
  3. Ensure proper error handling for all auth scenarios
  4. Contact backend team for role and permission questions

Ready to implement authentication? Check out the User Management API for profile and user data operations.