feat(settings): hub bearer-token panel + auto-attach on every RPC
Some checks failed
Security / Security check (push) Failing after 1s
Some checks failed
Security / Security check (push) Failing after 1s
Studio gains a new "HUB AUTHENTICATION" panel in Settings
mirroring the existing registry-credentials panel:
- `lib/data/hub_auth_token.dart` — `~/.fai/hub-auth-token`
helper (read / write / clear, mode 0600 on Unix). Sister
of `RegistryToken` with the same on-disk hygiene.
- `HubService.loadPersistedEndpoint` now reads the token at
startup and reconnects with it. `reconnect()` grew an
`authToken:` parameter with a sentinel that distinguishes
"keep current" from "drop". `reloadAuthToken()` is the
one-liner Settings calls after save / clear.
- `_HubAuthTokenPanel` in `fai_settings_dialog.dart` — paste
with show/hide toggle, save button, clear button, status
pill ("Configured (40 chars)" / "Not set (anonymous)"),
storage-location hint. Trimmed token length only — the
secret never round-trips back into the UI after save.
- EN + DE ARB entries (`hubAuthToken*`) + regenerated
`app_localizations.dart` keep the bilingual surface
consistent.
The hub side (auth.tokens config + tower middleware) shipped
on the platform side 2026-05-28 (43a54a2). Until now an
operator had no GUI path to consume it: they had to find the
file in their home dir, paste the token by hand, and bounce
Studio. This panel closes that loop.
Bumped pubspec to 0.47.0. dart analyze clean (No issues
found!); flutter test green (11 tests pass).
Block E item 1 of 4 done. Capability-picker badges,
default_scope editor, multi-version uninstall picker follow.
Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
3237c4415a
commit
c5a96bc8d4
9 changed files with 538 additions and 12 deletions
|
|
@ -12,6 +12,7 @@ import 'package:flutter/widgets.dart';
|
|||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'flow_output.dart';
|
||||
import 'hub_auth_token.dart';
|
||||
export 'flow_output.dart';
|
||||
|
||||
class HubService {
|
||||
|
|
@ -30,34 +31,54 @@ class HubService {
|
|||
static const _kPortKey = 'hub.port';
|
||||
static const _kSecureKey = 'hub.secure';
|
||||
|
||||
/// Read persisted endpoint and reconnect if it differs from
|
||||
/// the default. Called once at app start.
|
||||
/// Sentinel distinguishing "caller did not pass authToken"
|
||||
/// from "caller passed null to drop the token".
|
||||
static const Object _unset = Object();
|
||||
|
||||
/// Read persisted endpoint + auth token, then reconnect.
|
||||
/// Called once at app start; safe to call again after the
|
||||
/// operator updates the token in Settings.
|
||||
Future<void> loadPersistedEndpoint() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final host = prefs.getString(_kHostKey);
|
||||
final port = prefs.getInt(_kPortKey);
|
||||
final secure = prefs.getBool(_kSecureKey);
|
||||
if (host == null) return;
|
||||
final token = await HubAuthToken.read();
|
||||
if (host == null && token == null) return;
|
||||
final endpoint = HubEndpoint(
|
||||
host: host,
|
||||
port: port ?? 50051,
|
||||
secure: secure ?? false,
|
||||
host: host ?? _client.endpoint.host,
|
||||
port: port ?? _client.endpoint.port,
|
||||
secure: secure ?? _client.endpoint.secure,
|
||||
);
|
||||
if (endpoint.toString() != _client.endpoint.toString()) {
|
||||
await reconnect(endpoint);
|
||||
}
|
||||
await reconnect(endpoint, authToken: token);
|
||||
}
|
||||
|
||||
/// Reconnect to a new endpoint and persist for next launch.
|
||||
Future<void> reconnect(HubEndpoint endpoint) async {
|
||||
/// [authToken] is read from `~/.fai/hub-auth-token` by
|
||||
/// default — pass `null` to drop a previously-loaded token,
|
||||
/// or omit the parameter to keep the current value.
|
||||
Future<void> reconnect(
|
||||
HubEndpoint endpoint, {
|
||||
Object? authToken = _unset,
|
||||
}) async {
|
||||
await _client.close();
|
||||
_client = HubClient(endpoint: endpoint);
|
||||
final token = identical(authToken, _unset)
|
||||
? await HubAuthToken.read()
|
||||
: authToken as String?;
|
||||
_client = HubClient(endpoint: endpoint, authToken: token);
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_kHostKey, endpoint.host);
|
||||
await prefs.setInt(_kPortKey, endpoint.port);
|
||||
await prefs.setBool(_kSecureKey, endpoint.secure);
|
||||
}
|
||||
|
||||
/// Reload the token from disk and reconnect using the current
|
||||
/// endpoint. Called by Settings after the operator pastes or
|
||||
/// clears a token.
|
||||
Future<void> reloadAuthToken() async {
|
||||
await reconnect(_client.endpoint);
|
||||
}
|
||||
|
||||
static const _kThemeKey = 'theme.mode';
|
||||
static const _kLocaleKey = 'locale.code';
|
||||
|
||||
|
|
|
|||
85
lib/data/hub_auth_token.dart
Normal file
85
lib/data/hub_auth_token.dart
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import 'dart:io';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// Operator-managed gRPC bearer token used to authenticate
|
||||
/// Studio against a hub that has `auth.tokens:` configured
|
||||
/// (RBAC Level 2). Stored at `~/.fai/hub-auth-token` (mode
|
||||
/// 0600 on Unix). Sister of [RegistryToken] — same on-disk
|
||||
/// hygiene, different secret.
|
||||
///
|
||||
/// When the file is present and non-empty Studio constructs
|
||||
/// `HubClient(authToken: ...)`, which attaches
|
||||
/// `Authorization: Bearer <token>` to every gRPC and
|
||||
/// gRPC-Web call. When the file is missing Studio stays in
|
||||
/// anonymous mode; the hub then either accepts or rejects
|
||||
/// based on its own `auth.tokens:` config.
|
||||
class HubAuthToken {
|
||||
static String _faiHome() {
|
||||
final home = Platform.environment['HOME'] ??
|
||||
Platform.environment['USERPROFILE'];
|
||||
if (home == null || home.isEmpty) {
|
||||
throw StateError(
|
||||
'Cannot resolve home directory (no HOME / USERPROFILE)',
|
||||
);
|
||||
}
|
||||
return p.join(home, '.fai');
|
||||
}
|
||||
|
||||
/// Absolute path to the token file.
|
||||
static String get path => p.join(_faiHome(), 'hub-auth-token');
|
||||
|
||||
/// True iff the file exists with a non-empty trimmed body.
|
||||
static Future<bool> isConfigured() async {
|
||||
final f = File(path);
|
||||
if (!await f.exists()) return false;
|
||||
final content = await f.readAsString();
|
||||
return content.trim().isNotEmpty;
|
||||
}
|
||||
|
||||
/// Length of the trimmed token, or null when the file is
|
||||
/// missing / empty. Used for status display ("Configured
|
||||
/// (40 chars)") without ever surfacing the secret.
|
||||
static Future<int?> charCount() async {
|
||||
final f = File(path);
|
||||
if (!await f.exists()) return null;
|
||||
final content = await f.readAsString();
|
||||
final trimmed = content.trim();
|
||||
return trimmed.isEmpty ? null : trimmed.length;
|
||||
}
|
||||
|
||||
/// Read the trimmed token, or `null` when missing/empty.
|
||||
/// Studio calls this at startup so [HubService] can supply
|
||||
/// it to the underlying gRPC client.
|
||||
static Future<String?> read() async {
|
||||
final f = File(path);
|
||||
if (!await f.exists()) return null;
|
||||
final content = (await f.readAsString()).trim();
|
||||
return content.isEmpty ? null : content;
|
||||
}
|
||||
|
||||
/// Persist [token] to disk, creating `~/.fai/` if needed.
|
||||
/// On Unix the file is chmod-ed to 0600. Windows leaves the
|
||||
/// default user-ACL alone.
|
||||
static Future<void> write(String token) async {
|
||||
final f = File(path);
|
||||
await f.parent.create(recursive: true);
|
||||
await f.writeAsString(token.trim(), flush: true);
|
||||
if (Platform.isLinux || Platform.isMacOS) {
|
||||
try {
|
||||
await Process.run('chmod', ['600', f.path]);
|
||||
} catch (_) {
|
||||
// best-effort; default perms still confine to the
|
||||
// operator's machine.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the token file. Returns true iff the file existed
|
||||
/// before deletion.
|
||||
static Future<bool> clear() async {
|
||||
final f = File(path);
|
||||
if (!await f.exists()) return false;
|
||||
await f.delete();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue