diff --git a/lib/screens/common_widgets/auth/jwt_auth_fields.dart b/lib/screens/common_widgets/auth/jwt_auth_fields.dart index cdde66c2..44f97c98 100644 --- a/lib/screens/common_widgets/auth/jwt_auth_fields.dart +++ b/lib/screens/common_widgets/auth/jwt_auth_fields.dart @@ -21,6 +21,7 @@ class JwtAuthFields extends StatefulWidget { class _JwtAuthFieldsState extends State { late TextEditingController _secretController; + late TextEditingController _privateKeyController; late TextEditingController _payloadController; late String _addTokenTo; late String _algorithm; @@ -31,6 +32,7 @@ class _JwtAuthFieldsState extends State { super.initState(); final jwt = widget.authData?.jwt; _secretController = TextEditingController(text: jwt?.secret ?? ''); + _privateKeyController = TextEditingController(text: jwt?.privateKey ?? ''); _payloadController = TextEditingController(text: jwt?.payload ?? ''); _addTokenTo = jwt?.addTokenTo ?? 'header'; _algorithm = jwt?.algorithm ?? 'HS256'; @@ -83,6 +85,17 @@ class _JwtAuthFieldsState extends State { ('HS256', 'HS256'), ('HS384', 'HS384'), ('HS512', 'HS512'), + ('RS256', 'RS256'), + ('RS384', 'RS384'), + ('RS512', 'RS512'), + ('PS256', 'PS256'), + ('PS384', 'PS384'), + ('PS512', 'PS512'), + ('ES256', 'ES256'), + ('ES256K', 'ES256K'), + ('ES384', 'ES384'), + ('ES512', 'ES512'), + ('EdDSA', 'EdDSA'), ], tooltip: "Select JWT algorithm", isOutlined: true, @@ -96,33 +109,78 @@ class _JwtAuthFieldsState extends State { }, ), const SizedBox(height: 16), - AuthTextField( - readOnly: widget.readOnly, - controller: _secretController, - isObscureText: true, - hintText: "Secret key", - onChanged: (value) => _updateJwtAuth(), - ), - const SizedBox(height: 16), - CheckboxListTile( - title: Text( - "Secret is Base64 encoded", + if (_algorithm.startsWith('HS')) ...[ + AuthTextField( + readOnly: widget.readOnly, + controller: _secretController, + isObscureText: true, + hintText: "Secret key", + onChanged: (value) => _updateJwtAuth(), + ), + const SizedBox(height: 16), + CheckboxListTile( + title: Text( + "Secret is Base64 encoded", + style: TextStyle( + fontWeight: FontWeight.normal, + fontSize: 14, + ), + ), + value: _isSecretBase64Encoded, + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + onChanged: (bool? value) { + setState(() { + _isSecretBase64Encoded = value ?? false; + }); + + _updateJwtAuth(); + }, + ), + ] else ...[ + Text( + "Private Key (PEM Format)", style: TextStyle( fontWeight: FontWeight.normal, fontSize: 14, ), ), - value: _isSecretBase64Encoded, - contentPadding: EdgeInsets.zero, - controlAffinity: ListTileControlAffinity.leading, - onChanged: (bool? value) { - setState(() { - _isSecretBase64Encoded = value ?? false; - }); + SizedBox(height: 4), + TextField( + readOnly: widget.readOnly, + controller: _privateKeyController, + maxLines: 5, + decoration: InputDecoration( + filled: true, + fillColor: Theme.of(context).colorScheme.surfaceContainerLowest, + constraints: BoxConstraints( + maxWidth: MediaQuery.sizeOf(context).width - 100, + ), + contentPadding: const EdgeInsets.all(18), + hintText: ''' +-----BEGIN RSA PRIVATE KEY----- +Private Key in PKCS#8 PEM Format +-----END RSA PRIVATE KEY----- +''', + hintStyle: Theme.of(context).textTheme.bodyMedium, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline, + ), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + ), + ), + ), + onChanged: (value) => _updateJwtAuth(), + ), + ], - _updateJwtAuth(); - }, - ), const SizedBox(height: 16), Text( "Payload (JSON format)", @@ -284,6 +342,7 @@ class _JwtAuthFieldsState extends State { type: APIAuthType.jwt, jwt: AuthJwtModel( secret: _secretController.text.trim(), + privateKey: _privateKeyController.text.trim(), payload: _payloadController.text.trim(), addTokenTo: _addTokenTo, algorithm: _algorithm, diff --git a/packages/better_networking/better_networking_example/pubspec.lock b/packages/better_networking/better_networking_example/pubspec.lock index 908096b7..10c4eb73 100644 --- a/packages/better_networking/better_networking_example/pubspec.lock +++ b/packages/better_networking/better_networking_example/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + adaptive_number: + dependency: transitive + description: + name: adaptive_number + sha256: "3a567544e9b5c9c803006f51140ad544aedc79604fd4f3f2c1380003f97c1d77" + url: "https://pub.dev" + source: hosted + version: "1.0.0" async: dependency: transitive description: @@ -72,6 +80,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" + dart_jsonwebtoken: + dependency: transitive + description: + name: dart_jsonwebtoken + sha256: "21ce9f8a8712f741e8d6876a9c82c0f8a257fe928c4378a91d8527b92a3fd413" + url: "https://pub.dev" + source: hosted + version: "3.2.0" + ed25519_edwards: + dependency: transitive + description: + name: ed25519_edwards + sha256: "6ce0112d131327ec6d42beede1e5dfd526069b18ad45dcf654f15074ad9276cd" + url: "https://pub.dev" + source: hosted + version: "0.3.1" fake_async: dependency: transitive description: @@ -80,6 +104,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.3" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -210,6 +242,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.0" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5" + url: "https://pub.dev" + source: hosted + version: "4.0.0" seed: dependency: "direct overridden" description: diff --git a/packages/better_networking/lib/models/auth/auth_jwt_model.dart b/packages/better_networking/lib/models/auth/auth_jwt_model.dart index 61b165a5..a2e82e05 100644 --- a/packages/better_networking/lib/models/auth/auth_jwt_model.dart +++ b/packages/better_networking/lib/models/auth/auth_jwt_model.dart @@ -7,6 +7,7 @@ part 'auth_jwt_model.g.dart'; class AuthJwtModel with _$AuthJwtModel { const factory AuthJwtModel({ required String secret, + String? privateKey, required String payload, required String addTokenTo, required String algorithm, diff --git a/packages/better_networking/lib/models/auth/auth_jwt_model.freezed.dart b/packages/better_networking/lib/models/auth/auth_jwt_model.freezed.dart index c6d5fbf6..151e18f5 100644 --- a/packages/better_networking/lib/models/auth/auth_jwt_model.freezed.dart +++ b/packages/better_networking/lib/models/auth/auth_jwt_model.freezed.dart @@ -22,6 +22,7 @@ AuthJwtModel _$AuthJwtModelFromJson(Map json) { /// @nodoc mixin _$AuthJwtModel { String get secret => throw _privateConstructorUsedError; + String? get privateKey => throw _privateConstructorUsedError; String get payload => throw _privateConstructorUsedError; String get addTokenTo => throw _privateConstructorUsedError; String get algorithm => throw _privateConstructorUsedError; @@ -49,6 +50,7 @@ abstract class $AuthJwtModelCopyWith<$Res> { @useResult $Res call({ String secret, + String? privateKey, String payload, String addTokenTo, String algorithm, @@ -75,6 +77,7 @@ class _$AuthJwtModelCopyWithImpl<$Res, $Val extends AuthJwtModel> @override $Res call({ Object? secret = null, + Object? privateKey = freezed, Object? payload = null, Object? addTokenTo = null, Object? algorithm = null, @@ -89,6 +92,10 @@ class _$AuthJwtModelCopyWithImpl<$Res, $Val extends AuthJwtModel> ? _value.secret : secret // ignore: cast_nullable_to_non_nullable as String, + privateKey: freezed == privateKey + ? _value.privateKey + : privateKey // ignore: cast_nullable_to_non_nullable + as String?, payload: null == payload ? _value.payload : payload // ignore: cast_nullable_to_non_nullable @@ -134,6 +141,7 @@ abstract class _$$AuthJwtModelImplCopyWith<$Res> @useResult $Res call({ String secret, + String? privateKey, String payload, String addTokenTo, String algorithm, @@ -159,6 +167,7 @@ class __$$AuthJwtModelImplCopyWithImpl<$Res> @override $Res call({ Object? secret = null, + Object? privateKey = freezed, Object? payload = null, Object? addTokenTo = null, Object? algorithm = null, @@ -173,6 +182,10 @@ class __$$AuthJwtModelImplCopyWithImpl<$Res> ? _value.secret : secret // ignore: cast_nullable_to_non_nullable as String, + privateKey: freezed == privateKey + ? _value.privateKey + : privateKey // ignore: cast_nullable_to_non_nullable + as String?, payload: null == payload ? _value.payload : payload // ignore: cast_nullable_to_non_nullable @@ -211,6 +224,7 @@ class __$$AuthJwtModelImplCopyWithImpl<$Res> class _$AuthJwtModelImpl implements _AuthJwtModel { const _$AuthJwtModelImpl({ required this.secret, + this.privateKey, required this.payload, required this.addTokenTo, required this.algorithm, @@ -226,6 +240,8 @@ class _$AuthJwtModelImpl implements _AuthJwtModel { @override final String secret; @override + final String? privateKey; + @override final String payload; @override final String addTokenTo; @@ -242,7 +258,7 @@ class _$AuthJwtModelImpl implements _AuthJwtModel { @override String toString() { - return 'AuthJwtModel(secret: $secret, payload: $payload, addTokenTo: $addTokenTo, algorithm: $algorithm, isSecretBase64Encoded: $isSecretBase64Encoded, headerPrefix: $headerPrefix, queryParamKey: $queryParamKey, header: $header)'; + return 'AuthJwtModel(secret: $secret, privateKey: $privateKey, payload: $payload, addTokenTo: $addTokenTo, algorithm: $algorithm, isSecretBase64Encoded: $isSecretBase64Encoded, headerPrefix: $headerPrefix, queryParamKey: $queryParamKey, header: $header)'; } @override @@ -251,6 +267,8 @@ class _$AuthJwtModelImpl implements _AuthJwtModel { (other.runtimeType == runtimeType && other is _$AuthJwtModelImpl && (identical(other.secret, secret) || other.secret == secret) && + (identical(other.privateKey, privateKey) || + other.privateKey == privateKey) && (identical(other.payload, payload) || other.payload == payload) && (identical(other.addTokenTo, addTokenTo) || other.addTokenTo == addTokenTo) && @@ -270,6 +288,7 @@ class _$AuthJwtModelImpl implements _AuthJwtModel { int get hashCode => Object.hash( runtimeType, secret, + privateKey, payload, addTokenTo, algorithm, @@ -296,6 +315,7 @@ class _$AuthJwtModelImpl implements _AuthJwtModel { abstract class _AuthJwtModel implements AuthJwtModel { const factory _AuthJwtModel({ required final String secret, + final String? privateKey, required final String payload, required final String addTokenTo, required final String algorithm, @@ -311,6 +331,8 @@ abstract class _AuthJwtModel implements AuthJwtModel { @override String get secret; @override + String? get privateKey; + @override String get payload; @override String get addTokenTo; diff --git a/packages/better_networking/lib/models/auth/auth_jwt_model.g.dart b/packages/better_networking/lib/models/auth/auth_jwt_model.g.dart index 4e415d10..cdd58a28 100644 --- a/packages/better_networking/lib/models/auth/auth_jwt_model.g.dart +++ b/packages/better_networking/lib/models/auth/auth_jwt_model.g.dart @@ -9,6 +9,7 @@ part of 'auth_jwt_model.dart'; _$AuthJwtModelImpl _$$AuthJwtModelImplFromJson(Map json) => _$AuthJwtModelImpl( secret: json['secret'] as String, + privateKey: json['privateKey'] as String?, payload: json['payload'] as String, addTokenTo: json['addTokenTo'] as String, algorithm: json['algorithm'] as String, @@ -21,6 +22,7 @@ _$AuthJwtModelImpl _$$AuthJwtModelImplFromJson(Map json) => Map _$$AuthJwtModelImplToJson(_$AuthJwtModelImpl instance) => { 'secret': instance.secret, + 'privateKey': instance.privateKey, 'payload': instance.payload, 'addTokenTo': instance.addTokenTo, 'algorithm': instance.algorithm, diff --git a/packages/better_networking/lib/utils/auth/jwt_auth_utils.dart b/packages/better_networking/lib/utils/auth/jwt_auth_utils.dart index 303a3e90..791a3044 100644 --- a/packages/better_networking/lib/utils/auth/jwt_auth_utils.dart +++ b/packages/better_networking/lib/utils/auth/jwt_auth_utils.dart @@ -1,94 +1,95 @@ import 'dart:convert'; -import 'dart:typed_data'; +import 'dart:developer'; import 'package:better_networking/models/auth/auth_jwt_model.dart'; -import 'package:crypto/crypto.dart'; +import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; String generateJWT(AuthJwtModel jwtAuth) { try { - Map header; + // Parse header if provided + Map headerMap = {}; if (jwtAuth.header.isNotEmpty) { try { - header = json.decode(jwtAuth.header) as Map; + headerMap = json.decode(jwtAuth.header) as Map; } catch (e) { - header = {}; + // If header parsing fails, use empty header + headerMap = {}; } - } else { - header = {}; } - header['typ'] = header['typ'] ?? 'JWT'; - header['alg'] = jwtAuth.algorithm; - Map payload; + + // Parse payload if provided + Map payloadMap = {}; if (jwtAuth.payload.isNotEmpty) { try { - payload = json.decode(jwtAuth.payload) as Map; + payloadMap = json.decode(jwtAuth.payload) as Map; } catch (e) { - payload = {}; + // If payload parsing fails, use empty payload + payloadMap = {}; } - } else { - payload = {}; - } - if (!payload.containsKey('iat')) { - payload['iat'] = DateTime.now().millisecondsSinceEpoch ~/ 1000; } - // Encode header and payload - final encodedHeader = _base64UrlEncode(utf8.encode(json.encode(header))); - final encodedPayload = _base64UrlEncode(utf8.encode(json.encode(payload))); + // Add issued at time if not present + if (!payloadMap.containsKey('iat')) { + payloadMap['iat'] = DateTime.now().millisecondsSinceEpoch ~/ 1000; + } + final jwt = JWT(payloadMap, header: headerMap); - // Create signature - final signature = _createSignature( - '$encodedHeader.$encodedPayload', + final key = _createKey( jwtAuth.secret, jwtAuth.algorithm, jwtAuth.isSecretBase64Encoded, + jwtAuth.privateKey, + ); + final token = jwt.sign( + key, + algorithm: JWTAlgorithm.fromName(jwtAuth.algorithm), ); - return '$encodedHeader.$encodedPayload.$signature'; + return token; } catch (e) { - throw Exception('Failed to generate JWT: $e'); + log(e.toString()); + throw Exception('Failed to generate JSON Wweb Token: $e'); } } -String _createSignature( - String data, String secret, String algorithm, bool isSecretBase64Encoded) { - try { - Uint8List secretBytes; +JWTKey _createKey( + String secret, + String algorithm, + bool isSecretBase64Encoded, + String? privateKey, +) { + if (algorithm.startsWith('HS')) { if (isSecretBase64Encoded) { - secretBytes = base64.decode(secret); + final decodedSecret = base64.decode(secret); + return SecretKey(String.fromCharCodes(decodedSecret)); } else { - secretBytes = utf8.encode(secret); + return SecretKey(secret); } - - final dataBytes = utf8.encode(data); - - switch (algorithm) { - case 'HS256': - final hmac = Hmac(sha256, secretBytes); - final digest = hmac.convert(dataBytes); - return _base64UrlEncode(digest.bytes); - - case 'HS384': - final hmac = Hmac(sha384, secretBytes); - final digest = hmac.convert(dataBytes); - return _base64UrlEncode(digest.bytes); - - case 'HS512': - final hmac = Hmac(sha512, secretBytes); - final digest = hmac.convert(dataBytes); - return _base64UrlEncode(digest.bytes); - - default: - // Default to HS256 - final hmac = Hmac(sha256, secretBytes); - final digest = hmac.convert(dataBytes); - return _base64UrlEncode(digest.bytes); - } - } catch (e) { - // Return placeholder signature if creation fails - return _base64UrlEncode(utf8.encode('signature_generation_failed')); } -} + if (algorithm.startsWith('RS') || algorithm.startsWith('PS')) { + if (privateKey == null) { + throw Exception( + 'Failed to generate JSON Wweb Token: Private Key not Found', + ); + } + return RSAPrivateKey(privateKey); + } + if (algorithm.startsWith('ES')) { + if (privateKey == null) { + throw Exception( + 'Failed to generate JSON Wweb Token: Private Key not Found', + ); + } + return ECPrivateKey(privateKey); + } -String _base64UrlEncode(List bytes) { - return base64Url.encode(bytes).replaceAll('=', ''); + if (algorithm == 'EdDSA') { + if (privateKey == null) { + throw Exception( + 'Failed to generate JSON Wweb Token: Private Key not Found', + ); + } + return EdDSAPrivateKey.fromPEM(privateKey); + } + + return SecretKey(secret, isBase64Encoded: isSecretBase64Encoded); } diff --git a/packages/better_networking/pubspec.yaml b/packages/better_networking/pubspec.yaml index eeabb911..5214f399 100644 --- a/packages/better_networking/pubspec.yaml +++ b/packages/better_networking/pubspec.yaml @@ -26,7 +26,7 @@ dependencies: seed: ^0.0.3 xml: ^6.3.0 crypto: ^3.0.6 - convert: ^3.1.2 + dart_jsonwebtoken: ^3.2.0 dev_dependencies: flutter_test: diff --git a/packages/better_networking/test/utils/auth/jwt_auth_utils_test.dart b/packages/better_networking/test/utils/auth/jwt_auth_utils_test.dart new file mode 100644 index 00000000..00956038 --- /dev/null +++ b/packages/better_networking/test/utils/auth/jwt_auth_utils_test.dart @@ -0,0 +1,178 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:better_networking/models/auth/auth_jwt_model.dart'; +import 'package:better_networking/utils/auth/jwt_auth_utils.dart'; +import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; + +void main() { + group('JWT Auth Utils Tests', () { + test('should generate JWT with HS256 algorithm', () { + const jwtAuth = AuthJwtModel( + secret: 'test_secret', + payload: '{"user_id": 123, "username": "testuser"}', + addTokenTo: 'header', + algorithm: 'HS256', + isSecretBase64Encoded: false, + headerPrefix: 'Bearer', + queryParamKey: 'token', + header: '{"typ": "JWT"}', + ); + + final token = generateJWT(jwtAuth); + expect(token, isNotEmpty); + expect(token.split('.').length, equals(3)); // JWT has 3 parts + + // Verify the token can be decoded + final decoded = JWT.decode(token); + expect(decoded.payload['user_id'], equals(123)); + expect(decoded.payload['username'], equals('testuser')); + }); + + test('should generate JWT with HS384 algorithm', () { + const jwtAuth = AuthJwtModel( + secret: 'test_secret_384', + payload: '{"role": "admin"}', + addTokenTo: 'header', + algorithm: 'HS384', + isSecretBase64Encoded: false, + headerPrefix: 'Bearer', + queryParamKey: 'token', + header: '', + ); + + final token = generateJWT(jwtAuth); + expect(token, isNotEmpty); + expect(token.split('.').length, equals(3)); + + // Verify the token can be decoded + final decoded = JWT.decode(token); + expect(decoded.payload['role'], equals('admin')); + }); + + test('should generate JWT with HS512 algorithm', () { + const jwtAuth = AuthJwtModel( + secret: 'test_secret_512', + payload: '{"exp": 1234567890}', + addTokenTo: 'header', + algorithm: 'HS512', + isSecretBase64Encoded: false, + headerPrefix: 'Bearer', + queryParamKey: 'token', + header: '', + ); + + final token = generateJWT(jwtAuth); + expect(token, isNotEmpty); + expect(token.split('.').length, equals(3)); + + // Verify the token can be decoded + final decoded = JWT.decode(token); + expect(decoded.payload['exp'], equals(1234567890)); + }); + + test('should generate JWT with base64 encoded secret', () { + const secretBase64 = 'dGVzdF9zZWNyZXQ='; // base64 encoded "test_secret" + const jwtAuth = AuthJwtModel( + secret: secretBase64, + payload: '{"test": "value"}', + addTokenTo: 'header', + algorithm: 'HS256', + isSecretBase64Encoded: true, + headerPrefix: 'Bearer', + queryParamKey: 'token', + header: '', + ); + + final token = generateJWT(jwtAuth); + expect(token, isNotEmpty); + expect(token.split('.').length, equals(3)); + + // Verify the token can be decoded + final decoded = JWT.decode(token); + expect(decoded.payload['test'], equals('value')); + }); + + test('should handle empty payload', () { + const jwtAuth = AuthJwtModel( + secret: 'test_secret', + payload: '', + addTokenTo: 'header', + algorithm: 'HS256', + isSecretBase64Encoded: false, + headerPrefix: 'Bearer', + queryParamKey: 'token', + header: '', + ); + + final token = generateJWT(jwtAuth); + expect(token, isNotEmpty); + expect(token.split('.').length, equals(3)); + + // Verify the token can be decoded and has iat + final decoded = JWT.decode(token); + expect(decoded.payload['iat'], isNotNull); + }); + + test('should handle invalid JSON payload gracefully', () { + const jwtAuth = AuthJwtModel( + secret: 'test_secret', + payload: 'invalid json', + addTokenTo: 'header', + algorithm: 'HS256', + isSecretBase64Encoded: false, + headerPrefix: 'Bearer', + queryParamKey: 'token', + header: '', + ); + + final token = generateJWT(jwtAuth); + expect(token, isNotEmpty); + expect(token.split('.').length, equals(3)); + + // Should have at least iat in payload + final decoded = JWT.decode(token); + expect(decoded.payload['iat'], isNotNull); + }); + + test('should verify generated JWT with correct secret', () { + const secret = 'verification_secret'; + const jwtAuth = AuthJwtModel( + secret: secret, + payload: '{"user": "test"}', + addTokenTo: 'header', + algorithm: 'HS256', + isSecretBase64Encoded: false, + headerPrefix: 'Bearer', + queryParamKey: 'token', + header: '', + ); + + final token = generateJWT(jwtAuth); + + // Verify with correct secret + expect(() => JWT.verify(token, SecretKey(secret)), returnsNormally); + }); + + test('should fail verification with wrong secret', () { + const secret = 'correct_secret'; + const wrongSecret = 'wrong_secret'; + const jwtAuth = AuthJwtModel( + secret: secret, + payload: '{"user": "test"}', + addTokenTo: 'header', + algorithm: 'HS256', + isSecretBase64Encoded: false, + headerPrefix: 'Bearer', + queryParamKey: 'token', + header: '', + ); + + final token = generateJWT(jwtAuth); + + // Verify with wrong secret should throw + expect( + () => JWT.verify(token, SecretKey(wrongSecret)), + throwsA(isA()), + ); + }); + }); +}