huwiya_sdk 0.2.1
huwiya_sdk: ^0.2.1 copied to clipboard
OAuth 2.0 + PKCE authentication SDK for Flutter, backed by Huwiya ID. Reactive auth state, automatic token refresh, JWT claims validation.
Huwiya Flutter SDK #
OAuth 2.0 + PKCE authentication for Flutter apps, backed by Huwiya ID.
Features #
- OAuth 2.0 Authorization Code flow with PKCE (S256)
- Automatic token refresh with in-flight request deduplication
- Session restore on app launch
- JWT claims validation (
iss,aud,exp,iat, plus Huwiya-specific claims) - Reactive
AuthStatestream — works with any state management approach - First-class Riverpod providers
- Tokens stored in
flutter_secure_storage(Keychain / EncryptedSharedPreferences) - Typed exception hierarchy
Installation #
Add to your pubspec.yaml:
dependencies:
huwiya_sdk: ^0.1.0
Then run:
flutter pub get
Platform Setup #
Android #
In android/app/src/main/AndroidManifest.xml, add an intent filter inside your
<activity> tag to capture the redirect URI after authentication:
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="com.example.app" />
</intent-filter>
Replace com.example.app with your own redirect URI scheme.
iOS #
In ios/Runner/Info.plist, register your custom URL scheme:
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>com.example.app</string>
</array>
</dict>
</array>
Initialization #
Initialize the SDK once at app startup, before runApp. If any required config
value is missing or malformed, initialize throws a HuwiyaConfigException
immediately so you catch misconfiguration at startup rather than at runtime.
import 'package:flutter/material.dart';
import 'package:huwiya_sdk/huwiya_sdk.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final sdk = await HuwiyaSDK.initialize(
HuwiyaConfig(
baseUrl: 'https://id.example.com',
projectId: 'my-project-id',
clientId: 'my-client-id',
redirectUri: 'com.example.app://auth/callback',
scopes: ['openid', 'profile'],
),
);
runApp(MyApp(sdk: sdk));
}
After initialization, you can also reach the instance anywhere via:
final sdk = HuwiyaSDK.instance;
Calling
HuwiyaSDK.instancebeforeinitializethrowsStateError.
Clock-skew & issued-at validation #
Time-based JWT claims are validated against the device clock. Two optional config fields control how forgiving that validation is:
HuwiyaConfig(
// ...required fields...
clockSkewTolerance: const Duration(minutes: 5), // default
validateIssuedAt: false, // default
);
clockSkewTolerance— grace window applied to theexp(expiry) check (and toiatwhenvalidateIssuedAtis on). Defaults to 5 minutes, the conventional OIDC allowance.validateIssuedAt— whenfalse(the default), a token whoseiat(issued-at) is in the future does not fail sign-in. Aniatin the future almost always means the device clock is wrong, not that the token is invalid. Set it totrueto enforce strict issued-at checking.
Reactive Auth State #
The SDK exposes a broadcast Stream<AuthState> with five variants:
| Variant | Meaning |
|---|---|
Unauthenticated |
No active session |
Authenticating |
Sign-in flow is in progress |
Authenticated(user) |
Valid session; carries a HuwiyaUser |
Refreshing(user) |
Background token refresh; session is still valid |
AuthError(error) |
A typed HuwiyaException occurred |
Consume the stream with whichever approach fits your app:
Plain Flutter — StreamBuilder #
No dependencies beyond the SDK itself:
StreamBuilder<AuthState>(
stream: HuwiyaSDK.instance.authStateStream,
builder: (context, snapshot) {
final state = snapshot.data ?? const Unauthenticated();
return switch (state) {
Unauthenticated() => SignInButton(),
Authenticating() => const CircularProgressIndicator(),
Authenticated(:final user) => Text('Hello, ${user.name}!'),
Refreshing(:final user) => Text('Hello, ${user.name}! (refreshing…)'),
AuthError(:final error) => Text('Error: ${error.message}'),
};
},
)
Riverpod #
// Define this provider once, anywhere in your app:
final authStateProvider = StreamProvider<AuthState>(
(ref) => HuwiyaSDK.instance.authStateStream,
);
// In a ConsumerWidget:
final state = ref.watch(authStateProvider).valueOrNull ?? const Unauthenticated();
Bloc #
// Inside your AuthBloc constructor:
_subscription = HuwiyaSDK.instance.authStateStream.listen(
(state) => add(AuthStateChanged(state)),
);
GetX #
// Inside a GetxController:
final authState = Rx<AuthState>(const Unauthenticated());
@override
void onInit() {
super.onInit();
HuwiyaSDK.instance.authStateStream.listen((s) => authState.value = s);
}
Provider #
StreamProvider<AuthState>(
create: (_) => HuwiyaSDK.instance.authStateStream,
initialData: const Unauthenticated(),
)
Synchronous Snapshot #
When you need the current user without subscribing to the stream (e.g. inside a button callback), use the synchronous getter:
final user = HuwiyaSDK.instance.currentUser; // HuwiyaUser? — null if unauthenticated
Sign In #
try {
await HuwiyaSDK.instance.signIn();
// The authStateStream will emit Authenticated(user) on success.
} on UserCancelledException {
// User closed the browser — no action required.
} on HuwiyaException catch (e) {
print('Sign-in failed: ${e.message}');
}
Sign Out #
await HuwiyaSDK.instance.signOut();
// The authStateStream will emit Unauthenticated().
Getting a Valid Access Token #
The SDK refreshes tokens automatically when they are close to expiry. Use this method when you need to attach a token to your own HTTP client:
try {
final token = await HuwiyaSDK.instance.getAccessToken();
// Use token as a Bearer header value.
} on HuwiyaAuthException {
// No active session — redirect to sign-in.
}
Concurrent calls during an in-flight refresh are deduplicated — only one network request is made regardless of how many callers are waiting.
Error Handling #
All SDK errors extend HuwiyaException. Catch the specific type you care about,
or catch the base type as a fallback:
| Exception | Thrown when |
|---|---|
HuwiyaConfigException |
HuwiyaConfig is missing or malformed at init |
UserCancelledException |
User closed the browser during sign-in |
ProviderException |
Huwiya ID returned an error in the redirect |
StateMismatchException |
CSRF state parameter did not match |
MissingParametersException |
Redirect was missing code or state |
HuwiyaTokenException |
Network failure during token exchange or refresh |
TokenErrorResponseException |
Token endpoint returned a 4xx error body |
HuwiyaClaimsException |
JWT claims validation failed (which claim + reason included) |
HuwiyaStorageException |
Secure storage read/write failure |
try {
await sdk.signIn();
} on UserCancelledException {
// silent — user chose to cancel
} on ProviderException catch (e) {
showError('Provider error: ${e.errorDescription}');
} on HuwiyaClaimsException catch (e) {
showError('Token claim "${e.claim}" failed: ${e.reason}');
} on HuwiyaException catch (e) {
showError(e.message);
}
Minimal Example App #
A complete working example using plain StreamBuilder — no additional dependencies:
import 'package:flutter/material.dart';
import 'package:huwiya_sdk/huwiya_sdk.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await HuwiyaSDK.initialize(
HuwiyaConfig(
baseUrl: 'https://id.example.com',
projectId: 'proj_abc123',
clientId: 'client_xyz',
redirectUri: 'com.example.app://auth/callback',
scopes: ['openid', 'profile'],
),
);
runApp(const App());
}
class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) => const MaterialApp(home: AuthScreen());
}
class AuthScreen extends StatelessWidget {
const AuthScreen({super.key});
@override
Widget build(BuildContext context) {
final sdk = HuwiyaSDK.instance;
return Scaffold(
body: Center(
child: StreamBuilder<AuthState>(
stream: sdk.authStateStream,
builder: (context, snapshot) {
final state = snapshot.data ?? const Unauthenticated();
return switch (state) {
Unauthenticated() => ElevatedButton(
onPressed: () async {
try {
await sdk.signIn();
} on UserCancelledException {
// user cancelled — do nothing
} on HuwiyaException catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.message)),
);
}
},
child: const Text('Sign In'),
),
Authenticating() => const CircularProgressIndicator(),
Authenticated(:final user) => Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Welcome, ${user.name}!'),
const SizedBox(height: 16),
TextButton(
onPressed: () => sdk.signOut(),
child: const Text('Sign Out'),
),
],
),
Refreshing() => const CircularProgressIndicator(),
AuthError(:final error) => Text(
'Error: ${error.message}',
style: const TextStyle(color: Colors.red),
),
};
},
),
),
);
}
}
A complete runnable version lives in example/.
Contributing #
Issues and pull requests are welcome at https://github.com/hitaqnia/huwiya-flutter.
License #
Released under the MIT License © Hitaqnia.