A structurally different pattern from a web backend — there is no server sitting
between your app and this platform, so no BFF: your
Flutter app is a real public OAuth client (no client_secret —
it can't keep one, since the app binary is on the user's device). It talks to this
platform's endpoints directly, and the token lives on-device, not in a session cookie.
client_type: "public" when registering (see Organizations & Applications) —
no client_secret is issued for one, matching
PKCE's own security model: a public client proves possession of the authorization code
via the PKCE verifier, not a shared secret it can't actually protect.
WebView/InAppWebView is deliberately not used here. Three concrete reasons, not a vague preference:
accounts.onehux.com,
not a page your app is choosing to render that merely looks like it. An embedded
webview can silently show any URL with no way for the user to verify it.In practice: flutter_web_auth_2 (or
equivalent) — ASWebAuthenticationSession on
iOS, Chrome Custom Tabs on Android, both genuinely the system browser, not a webview.
import 'dart:convert';
import 'dart:math';
import 'package:crypto/crypto.dart';
import 'package:flutter_web_auth_2/flutter_web_auth_2.dart';
const onehuxBaseUrl = 'https://your-org.onehux.com'; // fixed, shared platform host — see the Integration overview
const clientId = 'onehux_client_...';
const callbackScheme = 'com.yourapp.app'; // registered as this Application's redirect_uri
const redirectUri = '$callbackScheme://callback';
String _base64url(List<int> bytes) =>
base64Url.encode(bytes).replaceAll('=', '');
Future<Map<String, String>> signIn() async {
final verifierBytes = List<int>.generate(48, (_) => Random.secure().nextInt(256));
final codeVerifier = _base64url(verifierBytes);
final codeChallenge = _base64url(sha256.convert(utf8.encode(codeVerifier)).bytes);
final state = _base64url(List<int>.generate(16, (_) => Random.secure().nextInt(256)));
final authUrl = Uri.parse('$onehuxBaseUrl/login').replace(
queryParameters: {
'client_id': clientId,
'redirect_uri': redirectUri,
'code_challenge': codeChallenge,
'code_challenge_method': 'S256',
'scope': 'openid profile email',
'state': state,
},
);
// Opens the real system browser (ASWebAuthenticationSession / Custom Tabs), never a
// WebView — see "Why the system browser" above. Suspends until the browser redirects
// back to redirectUri, which the OS hands back to this app via the registered scheme.
final resultUrl = await FlutterWebAuth2.authenticate(
url: authUrl.toString(),
callbackUrlScheme: callbackScheme,
);
final result = Uri.parse(resultUrl);
if (result.queryParameters['state'] != state) {
throw Exception('OAuth state mismatch.');
}
final code = result.queryParameters['code']!;
return _exchangeCodeForTokens(code: code, codeVerifier: codeVerifier);
}import 'package:http/http.dart' as http;
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
Future<Map<String, String>> _exchangeCodeForTokens({
required String code,
required String codeVerifier,
}) async {
final res = await http.post(
Uri.parse('$onehuxBaseUrl/api/v1/oauth/token/'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'grant_type': 'authorization_code',
'code': code,
'redirect_uri': redirectUri,
'client_id': clientId,
// No client_secret — a public client authenticates via the PKCE verifier alone.
'code_verifier': codeVerifier,
}),
);
if (res.statusCode != 200) {
throw Exception('Sign-in failed.');
}
final tokens = jsonDecode(res.body) as Map<String, dynamic>;
// {access_token, id_token, refresh_token, token_type, expires_in, scope}
// OS-level secure storage — Keychain on iOS, Keystore-backed EncryptedSharedPreferences
// on Android — never plain SharedPreferences/UserDefaults, and never a cookie (there is
// no BFF here; this app itself holds the token).
const storage = FlutterSecureStorage();
await storage.write(key: 'onehux_access_token', value: tokens['access_token'] as String);
await storage.write(key: 'onehux_refresh_token', value: tokens['refresh_token'] as String);
return tokens.cast<String, String>();
}A public client's refresh token rotates on every use, same as a confidential client's,
but with a shorter lifetime (7-day idle timeout, 14-day absolute cap — see Sessions) — no client_secret is sent, matching every other
public-client call this app makes. A 401 from /userinfo almost always means just the 15-minute access token expired, not that the user needs to
sign in again:
Future<String?> _refreshTokens() async {
const storage = FlutterSecureStorage();
final refreshToken = await storage.read(key: 'onehux_refresh_token');
if (refreshToken == null) return null;
final res = await http.post(
Uri.parse('$onehuxBaseUrl/api/v1/oauth/token/'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'grant_type': 'refresh_token',
'refresh_token': refreshToken,
'client_id': clientId,
// No client_secret — same public-client rule as the code exchange above.
}),
);
if (res.statusCode != 200) return null;
final tokens = jsonDecode(res.body) as Map<String, dynamic>;
await storage.write(key: 'onehux_access_token', value: tokens['access_token'] as String);
await storage.write(key: 'onehux_refresh_token', value: tokens['refresh_token'] as String);
return tokens['access_token'] as String;
}
Future<Map<String, dynamic>?> fetchUserInfo() async {
const storage = FlutterSecureStorage();
var accessToken = await storage.read(key: 'onehux_access_token');
if (accessToken == null) return null;
var res = await http.get(
Uri.parse('$onehuxBaseUrl/api/v1/oauth/userinfo/'),
headers: {'Authorization': 'Bearer $accessToken'},
);
if (res.statusCode == 401) {
accessToken = await _refreshTokens();
if (accessToken == null) {
await storage.delete(key: 'onehux_access_token');
await storage.delete(key: 'onehux_refresh_token');
return null;
}
res = await http.get(
Uri.parse('$onehuxBaseUrl/api/v1/oauth/userinfo/'),
headers: {'Authorization': 'Bearer $accessToken'},
);
}
if (res.statusCode != 200) return null;
return jsonDecode(res.body) as Map<String, dynamic>; // sub, name, email, picture, roles, ...
}