← Integration guides

Flutter integration

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.

Register a public client

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.

Why the system browser, never an embedded webview

WebView/InAppWebView is deliberately not used here. Three concrete reasons, not a vague preference:

  1. An embedded webview runs inside your own app process — your app (or any library it depends on) could inspect the page, inject script, or read cookies/form input as the user types their credentials. The system browser is a separate, sandboxed process the host app has no access into.
  2. The system browser gives the user its own real address bar and TLS certificate validation — genuine proof they're really on 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.
  3. This is the industry-standard guidance for native apps doing OAuth (RFC 8252, "OAuth 2.0 for Native Apps"), which explicitly recommends the system browser and advises against embedded webviews for exactly this reason — this isn't a OneHux-specific requirement, it's the same reasoning any OAuth provider's own native-app guidance gives.

In practice: flutter_web_auth_2 (or equivalent) — ASWebAuthenticationSession on iOS, Chrome Custom Tabs on Android, both genuinely the system browser, not a webview.

1. PKCE + launch the system browser

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);
}

2. Exchange the code — no client_secret, public client

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>();
}

3. Using the token — and refreshing it

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, ...
}