From 64b2c0945face1d3b8fd0be567fda4e1904a15f1 Mon Sep 17 00:00:00 2001 From: ODevStudio Date: Wed, 19 Aug 2026 23:53:48 +0200 Subject: [PATCH 1/4] feat(account): require proxy caller consent --- .agents/skills/decent-app/SKILL.md | 1 + .../scenarios/account-proxy-consent.md | 84 ++++++++ assets/api/rest_v1.yml | 12 +- doc/AI_BUILD_NOTES.md | 4 +- doc/Api.md | 2 +- doc/Plugins.md | 2 +- .../archive/account-proxy-consent/design.md | 32 +++ lib/main.dart | 32 +++ lib/src/account/account_consent_prompter.dart | 83 ++++++++ lib/src/cli/cli_args.dart | 15 ++ .../account/account_consent_gate.dart | 148 ++++++++++++++ .../account/account_consent_store.dart | 96 +++++++++ .../account/decent_proxy_service.dart | 33 +++- .../webserver/account_proxy_handler.dart | 4 + .../account_consent_prompter_test.dart | 79 ++++++++ test/cli_options_test.dart | 20 ++ .../plugin_decent_proxy_bridge_test.dart | 35 +++- ...ugin_manager_decent_proxy_bridge_test.dart | 1 + ...n_manager_decent_proxy_ownership_test.dart | 1 + .../plugins/plugin_manager_workload_test.dart | 1 + test/plugins/shot_upload_plugin_test.dart | 1 + .../account/account_consent_gate_test.dart | 186 ++++++++++++++++++ .../account/account_consent_store_test.dart | 50 +++++ .../account/decent_proxy_service_test.dart | 24 +++ .../webserver/account_proxy_handler_test.dart | 33 ++++ 25 files changed, 965 insertions(+), 14 deletions(-) create mode 100644 .agents/skills/decent-app/scenarios/account-proxy-consent.md create mode 100644 doc/plans/archive/account-proxy-consent/design.md create mode 100644 lib/src/account/account_consent_prompter.dart create mode 100644 lib/src/services/account/account_consent_gate.dart create mode 100644 lib/src/services/account/account_consent_store.dart create mode 100644 test/account/account_consent_prompter_test.dart create mode 100644 test/services/account/account_consent_gate_test.dart create mode 100644 test/services/account/account_consent_store_test.dart diff --git a/.agents/skills/decent-app/SKILL.md b/.agents/skills/decent-app/SKILL.md index 5e56def09..e903c764a 100644 --- a/.agents/skills/decent-app/SKILL.md +++ b/.agents/skills/decent-app/SKILL.md @@ -47,6 +47,7 @@ Pick the scenario that matches the task, run it verbatim, and finish before call | Bengle firmware wake-schedule sync | `scenarios/bengle-wake-schedule.md` | | Account-proxy CORS pinned to skin origin | `scenarios/account-proxy-cors.md` | | Account-proxy write forwarding + write-scope gate | `scenarios/account-proxy-write.md` | +| Account-proxy native consent gate | `scenarios/account-proxy-consent.md` | | Plugin Decent-account proxy bridge (host.decentProxy) | `scenarios/plugin-decent-proxy.md` | ## Authoritative sources diff --git a/.agents/skills/decent-app/scenarios/account-proxy-consent.md b/.agents/skills/decent-app/scenarios/account-proxy-consent.md new file mode 100644 index 000000000..72f4478a7 --- /dev/null +++ b/.agents/skills/decent-app/scenarios/account-proxy-consent.md @@ -0,0 +1,84 @@ +# Scenario: Account-proxy native consent gate + +Verifies that the first linked-account proxy request for a skin pauses for a +trusted native prompt, denial returns `403` without contacting the upstream, +and an explicit session trust grant forwards without a prompt. Use a linked +Decent account and the read-only `support/api/sn` endpoint. + +## Preconditions + +Run this on a desktop with a linked account and an installed `streamline.js` +skin. The denial check uses a disposable custom skin path so it cannot lock the +normal installed skin out. `flutter run` uses `--dart-entrypoint-args` because +consent trust is a process argument, not a Dart define. + +```bash +TMP=$(mktemp -d) +printf 'Consent smoke\n' \ + > "$TMP/index.html" +./flutter_with_commit.sh run -d macos --dart-define=simulate=1 \ + --dart-entrypoint-args=--skin-path="$TMP" +``` + +In another terminal, wait for the servers and obtain the injected skin token: + +```bash +until curl -sf http://localhost:8080/api/v1/info >/dev/null; do sleep 1; done +P=/api/v1/account/proxy/support/api/sn +TOK=$(curl -s http://localhost:3000/ \ + | sed -n 's/.*name="reaprime-proxy-token" content="\([^"]*\)".*/\1/p' \ + | head -1) +test -n "$TOK" +``` + +## Steps + +Start a request and leave it waiting while the native dialog is visible: + +```bash +curl -sS -w '\nHTTP %{http_code}\n' \ + -H "Authorization: Bearer $TOK" "http://localhost:8080$P" +``` + +Choose **Deny** on the Decaid device. The request must finish with: + +```text +{"error":"Account access was not granted"} +HTTP 403 +``` + +Stop the app, then start it with explicit session trust: + +```bash +./flutter_with_commit.sh run -d macos --dart-define=simulate=1 \ + --dart-entrypoint-args=--skin=streamline.js \ + --dart-entrypoint-args=--trust-consent=skin:streamline.js +``` + +Fetch the new process token and repeat the request: + +```bash +until curl -sf http://localhost:8080/api/v1/info >/dev/null; do sleep 1; done +TOK=$(curl -s http://localhost:3000/ \ + | sed -n 's/.*name="reaprime-proxy-token" content="\([^"]*\)".*/\1/p' \ + | head -1) +status=$(curl -sS -o /tmp/decaid-consent-body -w '%{http_code}' \ + -H "Authorization: Bearer $TOK" "http://localhost:8080$P") +test "$status" != "403" +cat /tmp/decaid-consent-body +``` + +No consent dialog should appear in the trusted run. With a valid linked account, +the response is the upstream serial-number result rather than Decaid's consent +error. + +## Postconditions + +Stop `flutter run` with `q`, then remove the disposable skin: + +```bash +rm -rf "$TMP" +``` + +The deny remains scoped to the hash of that disposable path and cannot affect +an installed skin. The session trust override is gone when the process exits. diff --git a/assets/api/rest_v1.yml b/assets/api/rest_v1.yml index 66e6d68a9..6e1fcf065 100644 --- a/assets/api/rest_v1.yml +++ b/assets/api/rest_v1.yml @@ -5168,7 +5168,8 @@ paths: token (injected into served skin pages) or a user-managed API-client token. Unauthenticated/unknown tokens get 401; known but unscoped get 403; a path outside the allowed prefix gets 403; no linked account gets - 401. + 401. A caller's first linked-account request pauses for native consent; + denial or a 30-second timeout gets 403 before any upstream request. tags: [Account] parameters: - name: endpoint @@ -5194,7 +5195,7 @@ paths: schema: $ref: "#/components/schemas/Error" "403": - description: Token not scoped for account:proxy, or path not allowed + description: Token unscoped, path disallowed, or account consent not granted content: application/json: schema: @@ -5211,7 +5212,8 @@ paths: Requires `Authorization: Bearer ` scoped `account:proxy:write`. The read-only skin token cannot write — it gets 403. Other rejection rules match the GET form (401 unknown token / no - linked account; 403 unscoped or disallowed path). + linked account; 403 unscoped, disallowed path, or account consent not + granted). tags: [Account] parameters: - name: endpoint @@ -5245,7 +5247,7 @@ paths: schema: $ref: "#/components/schemas/Error" "403": - description: Token not scoped for account:proxy:write, or path not allowed + description: Token unscoped, path disallowed, or account consent not granted content: application/json: schema: @@ -5288,7 +5290,7 @@ paths: schema: $ref: "#/components/schemas/Error" "403": - description: Token not scoped for account:proxy:write, or path not allowed + description: Token unscoped, path disallowed, or account consent not granted content: application/json: schema: diff --git a/doc/AI_BUILD_NOTES.md b/doc/AI_BUILD_NOTES.md index 779525c21..4aeb4af16 100644 --- a/doc/AI_BUILD_NOTES.md +++ b/doc/AI_BUILD_NOTES.md @@ -193,9 +193,11 @@ The app supports several command-line flags for headless/calibration-station use --skin= # Pre-select skin by ID --skin-path= # Pre-select skin by filesystem path --no-account # Skip DecentAccountService (headless Linux with no desktop session) + --trust-consent= # Trust one account-proxy caller for this process; repeatable + --trust-all-consent # Trust every account-proxy caller for this process ``` -All flags are optional. Combine as needed. `--no-account` is specifically for headless Linux stations where `libsecret` blocks on XDG secrets portal. +All flags are optional. Combine as needed. `--no-account` is specifically for headless Linux stations where `libsecret` blocks on XDG secrets portal. Consent keys use `skin:`, `plugin:`, or `api:`; both trust flags are session-only and are never persisted. With `flutter run`, pass each app flag separately as `--dart-entrypoint-args=`; `--dart-define` does not populate `main()` arguments. ## Dev-Loop Skill diff --git a/doc/Api.md b/doc/Api.md index 882fdf1d7..dd0619913 100644 --- a/doc/Api.md +++ b/doc/Api.md @@ -450,7 +450,7 @@ archive is also bounded by the 2 GiB import request limit. Linking/unlinking a Decent account is **native-only** — there are no network login/logout routes. The webserver is unauthenticated with `Access-Control-Allow-Origin: *`, so exposing credential operations would let any LAN client or browser origin store attacker credentials or unlink the account. The status response omits the linked email (PII). -The **proxy** lets clients *use* the account without ever seeing the credentials: it attaches the linked account's Basic auth server-side, forwards to `decentespresso.com`, and relays the upstream status + body verbatim. It requires `Authorization: Bearer ` and is enforced only on this path. `GET` requires `account:proxy` (including the skin token injected into served skin pages); `POST`/`PUT` require the stronger `account:proxy:write` scope, so the read-only skin token cannot write. Forwarding is restricted to the `support/api/` prefix. The OpenAPI spec documents the generated-client-safe `/support/api/{endpoint}` form; use this raw catch-all route when a Decent backend path contains additional slashes. Responses: 401 (missing/invalid token or no linked account), 403 (token unscoped or path not allowed). Write-scoped tokens are minted from the account page's API-token UI by enabling "Allow write access". +The **proxy** lets clients *use* the account without ever seeing the credentials: it attaches the linked account's Basic auth server-side, forwards to `decentespresso.com`, and relays the upstream status + body verbatim. It requires `Authorization: Bearer ` and is enforced only on this path. `GET` requires `account:proxy` (including the skin token injected into served skin pages); `POST`/`PUT` require the stronger `account:proxy:write` scope, so the read-only skin token cannot write. Forwarding is restricted to the `support/api/` prefix. The OpenAPI spec documents the generated-client-safe `/support/api/{endpoint}` form; use this raw catch-all route when a Decent backend path contains additional slashes. The first request from each skin, plugin, or named API client pauses for native approval on the Decaid device. Explicit allow and deny decisions are remembered; a 30-second timeout denies only that request. Responses: 401 (missing/invalid token or no linked account), 403 (token unscoped, path not allowed, or account access not granted). Write-scoped tokens are minted from the account page's API-token UI by enabling "Allow write access". Headless operators can grant session-only access with `--trust-consent=` or `--trust-all-consent`. ### Other diff --git a/doc/Plugins.md b/doc/Plugins.md index d48cdb639..7971e7662 100644 --- a/doc/Plugins.md +++ b/doc/Plugins.md @@ -146,7 +146,7 @@ host.storage({ **Note:** namespace is not used by Decaid internally, the plugin storage is namespaced to the plugins' identifier. ### `host.decentProxy(path, options)` -Call the Decent account proxy without exposing stored credentials to plugin code. `GET` requires the read-only `proxy.decent_api` permission. `POST` requires the distinct write permission `proxy.decent_api.write` **and** is restricted to an explicit path allowlist (currently only `support/api/shot_upload`); other methods/paths are rejected and logged. +Call the Decent account proxy without exposing stored credentials to plugin code. `GET` requires the read-only `proxy.decent_api` permission. `POST` requires the distinct write permission `proxy.decent_api.write` **and** is restricted to an explicit path allowlist (currently only `support/api/shot_upload`); other methods/paths are rejected and logged. The first request from each plugin id pauses for approval in Decaid's native UI. Explicit allow and deny decisions are remembered; denial or timeout rejects the call before any upstream request. ```javascript const response = await host.decentProxy("support/api/sn", { diff --git a/doc/plans/archive/account-proxy-consent/design.md b/doc/plans/archive/account-proxy-consent/design.md new file mode 100644 index 000000000..ba0c6de84 --- /dev/null +++ b/doc/plans/archive/account-proxy-consent/design.md @@ -0,0 +1,32 @@ +# Account proxy consent gate + +## Goal + +Require a remembered native allow or deny decision before any skin, plugin, or +named API client can use the linked Decent account. + +## Design + +- Keep bearer-token scope checks in `proxyAuthMiddleware`. +- Enforce consent in `DecentProxyService`, the shared path used by HTTP clients + and `host.decentProxy` plugins, before any upstream request. +- Resolve the generic skin caller to `skin:`. For an + unregistered served path, use `skin:path:`. If no + active skin can be identified, deny without prompting. +- Persist explicit allow and deny decisions in the existing credential store. + A timeout or unavailable navigator denies without persistence. +- Coalesce concurrent requests for one caller onto one prompt. +- A 30-second native dialog timeout defaults to deny. The dialog cannot be + dismissed by tapping outside it. +- CLI trust keys are session-only and evaluated before persisted decisions. + +## Verification + +1. Unit-test storage round trips, caller identity, remembered decisions, + coalescing, timeout behavior, headless denial, and CLI trust. +2. Prove the shared proxy service neither forwards nor exposes credentials when + consent is denied, including the plugin caller path. +3. Widget-test allow, deny, timeout, and rendering over the active full-screen + route. +4. Update REST and plugin documentation, format, analyze, and run the full test + suite. diff --git a/lib/main.dart b/lib/main.dart index 132fcc2dd..e79be99e7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,6 +4,7 @@ import 'dart:ui' show AppExitResponse, AppExitType; import 'package:collection/collection.dart'; import 'package:hive_ce_flutter/hive_flutter.dart'; +import 'package:path/path.dart' as p; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -13,6 +14,7 @@ import 'package:flutter_foreground_task/flutter_foreground_task.dart'; import 'package:logging/logging.dart'; import 'package:logging_appenders/logging_appenders.dart'; import 'package:reaprime/build_info.dart'; +import 'package:reaprime/src/account/account_consent_prompter.dart'; import 'package:reaprime/src/controllers/battery_controller.dart'; import 'package:reaprime/src/controllers/bengle_probe_bridge.dart'; import 'package:reaprime/src/controllers/bengle_saw_bridge.dart'; @@ -52,6 +54,8 @@ import 'package:reaprime/src/services/storage/bean_storage_service.dart'; import 'package:reaprime/src/services/storage/drift_storage_service.dart'; import 'package:reaprime/src/services/storage/grinder_storage_service.dart'; import 'package:reaprime/src/services/storage/profile_storage_service.dart'; +import 'package:reaprime/src/services/account/account_consent_gate.dart'; +import 'package:reaprime/src/services/account/account_consent_store.dart'; import 'package:reaprime/src/services/account/decent_account_service.dart'; import 'package:reaprime/src/services/account/decent_proxy_service.dart'; import 'package:reaprime/src/services/account/proxy_token_service.dart'; @@ -155,6 +159,23 @@ Future _printStoragePaths() async { exit(0); } +ActiveSkinConsent? _activeSkinConsent( + WebUIService service, + WebUIStorage storage, +) { + final path = service.serverPath().trim(); + if (path.isEmpty) return null; + final normalizedPath = p.normalize(path); + final skin = storage.installedSkins.firstWhereOrNull( + (candidate) => p.equals(p.normalize(candidate.path), normalizedPath), + ); + return ActiveSkinConsent( + id: skin?.id, + name: skin?.name ?? 'Custom skin', + path: path, + ); +} + void main(List args) async { WidgetsFlutterBinding.ensureInitialized(); final cliArgs = parseCliArgs(args); @@ -402,6 +423,16 @@ void main(List args) async { decentProxyService = null; } else { credentialStore = await createCredentialStore(); + final consentPrompter = AccountConsentPrompter( + navigatorKey: NavigationService.navigatorKey, + ); + final consentGate = AccountConsentGate( + store: AccountConsentStore(credentialStore: credentialStore), + activeSkin: () => _activeSkinConsent(webUIService, webUIStorage), + prompt: consentPrompter.prompt, + trustedConsentKeys: cliArgs.trustedConsentKeys, + trustAllConsent: cliArgs.trustAllConsent, + ); const decentBaseUrl = String.fromEnvironment( 'DECENT_BASE_URL', defaultValue: 'https://decentespresso.com', @@ -414,6 +445,7 @@ void main(List args) async { decentProxyService = DecentProxyService( httpClient: http.Client(), credentialStore: credentialStore, + requireConsent: consentGate.requireConsent, baseUrl: decentBaseUrl, ); accountTokensController = AccountTokensController( diff --git a/lib/src/account/account_consent_prompter.dart b/lib/src/account/account_consent_prompter.dart new file mode 100644 index 000000000..2a1e798d7 --- /dev/null +++ b/lib/src/account/account_consent_prompter.dart @@ -0,0 +1,83 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:reaprime/src/services/account/account_consent_store.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +class AccountConsentPrompter { + final GlobalKey _navigatorKey; + final Duration timeout; + + AccountConsentPrompter({ + required GlobalKey navigatorKey, + this.timeout = const Duration(seconds: 30), + }) : _navigatorKey = navigatorKey; + + Future prompt(String callerLabel) { + final context = _navigatorKey.currentContext; + if (context == null || !context.mounted) return Future.value(); + + return showShadDialog( + context: context, + barrierDismissible: false, + builder: (context) => + _AccountConsentDialog(callerLabel: callerLabel, timeout: timeout), + ); + } +} + +class _AccountConsentDialog extends StatefulWidget { + final String callerLabel; + final Duration timeout; + + const _AccountConsentDialog({ + required this.callerLabel, + required this.timeout, + }); + + @override + State<_AccountConsentDialog> createState() => _AccountConsentDialogState(); +} + +class _AccountConsentDialogState extends State<_AccountConsentDialog> { + late final Timer _timer; + + @override + void initState() { + super.initState(); + _timer = Timer(widget.timeout, () { + if (mounted) Navigator.of(context).pop(); + }); + } + + @override + void dispose() { + _timer.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ShadDialog( + title: const Text('Decent account access'), + description: Text( + '${widget.callerLabel} wants to use your linked Decent account.', + ), + actions: [ + ShadButton.outline( + onPressed: () => + Navigator.of(context).pop(AccountConsentDecision.denied), + child: const Text('Deny'), + ), + ShadButton( + onPressed: () => + Navigator.of(context).pop(AccountConsentDecision.allowed), + child: const Text('Allow'), + ), + ], + child: const Text( + 'This request may come from another device on your local network.', + ), + ); + } +} diff --git a/lib/src/cli/cli_args.dart b/lib/src/cli/cli_args.dart index 0183dd3d8..49a638bbf 100644 --- a/lib/src/cli/cli_args.dart +++ b/lib/src/cli/cli_args.dart @@ -6,6 +6,8 @@ class CliArgs { final bool direct; final bool noAccount; final bool printStoragePaths; + final Set trustedConsentKeys; + final bool trustAllConsent; final String? skinId; final String? skinPath; @@ -15,6 +17,8 @@ class CliArgs { this.direct = false, this.noAccount = false, this.printStoragePaths = false, + this.trustedConsentKeys = const {}, + this.trustAllConsent = false, this.skinId, this.skinPath, }); @@ -43,6 +47,15 @@ CliArgs parseCliArgs(List args) { 'print-storage-paths', help: 'Print resolved application storage paths and exit.', defaultsTo: false, + ) + ..addMultiOption( + 'trust-consent', + help: 'Trust an account-consent key for this process.', + ) + ..addFlag( + 'trust-all-consent', + help: 'Trust every account-proxy caller for this process.', + defaultsTo: false, ); final results = parser.parse(args); @@ -52,6 +65,8 @@ CliArgs parseCliArgs(List args) { direct: results['direct'] as bool, noAccount: results['no-account'] as bool, printStoragePaths: results['print-storage-paths'] as bool, + trustedConsentKeys: (results['trust-consent'] as List).toSet(), + trustAllConsent: results['trust-all-consent'] as bool, skinId: results['skin'] as String?, skinPath: results['skin-path'] as String?, ); diff --git a/lib/src/services/account/account_consent_gate.dart b/lib/src/services/account/account_consent_gate.dart new file mode 100644 index 000000000..ccfa83a98 --- /dev/null +++ b/lib/src/services/account/account_consent_gate.dart @@ -0,0 +1,148 @@ +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as p; +import 'package:reaprime/src/services/account/account_consent_store.dart'; + +typedef AccountConsentPrompt = + Future Function(String callerLabel); + +class ActiveSkinConsent { + final String? id; + final String name; + final String path; + + const ActiveSkinConsent({this.id, required this.name, required this.path}); +} + +class AccountConsentGate { + final AccountConsentStore _store; + final ActiveSkinConsent? Function() _activeSkin; + final AccountConsentPrompt _prompt; + final Set _trustedConsentKeys; + final bool _trustAllConsent; + final Logger _log; + final Map> _pending = {}; + + AccountConsentGate({ + required AccountConsentStore store, + required ActiveSkinConsent? Function() activeSkin, + required AccountConsentPrompt prompt, + Set trustedConsentKeys = const {}, + bool trustAllConsent = false, + Logger? log, + }) : _store = store, + _activeSkin = activeSkin, + _prompt = prompt, + _trustedConsentKeys = Set.unmodifiable( + trustedConsentKeys + .map((key) => key.trim()) + .where((key) => key.isNotEmpty), + ), + _trustAllConsent = trustAllConsent, + _log = log ?? Logger('AccountConsentGate'); + + Future requireConsent(String callerId) async { + final id = callerId.trim(); + if (_trustAllConsent && id == 'skin') return true; + + final subject = _subjectFor(id); + if (subject == null) return false; + if (_trustAllConsent || _trustedConsentKeys.contains(subject.key)) { + return true; + } + + final pending = _pending[subject.key]; + if (pending != null) return pending; + + final AccountConsentDecision? known; + try { + known = await _store.read(subject.key); + } catch (error, stackTrace) { + _log.warning( + 'Failed to read consent for ${subject.key}', + error, + stackTrace, + ); + return false; + } + if (known != null) return known == AccountConsentDecision.allowed; + + final pendingAfterRead = _pending[subject.key]; + if (pendingAfterRead != null) return pendingAfterRead; + + final request = _promptAndPersist(subject); + _pending[subject.key] = request; + try { + return await request; + } finally { + if (identical(_pending[subject.key], request)) { + _pending.remove(subject.key); + } + } + } + + Future _promptAndPersist(_ConsentSubject subject) async { + final AccountConsentDecision? decision; + try { + decision = await _prompt(subject.label); + } catch (error, stackTrace) { + _log.warning( + 'Consent prompt failed for ${subject.key}', + error, + stackTrace, + ); + return false; + } + if (decision == null) return false; + + try { + await _store.write(subject.key, decision); + } catch (error, stackTrace) { + _log.warning( + 'Failed to persist consent for ${subject.key}', + error, + stackTrace, + ); + return false; + } + return decision == AccountConsentDecision.allowed; + } + + _ConsentSubject? _subjectFor(String callerId) { + if (callerId == 'skin') { + final skin = _activeSkin(); + if (skin == null || skin.path.trim().isEmpty) return null; + final id = skin.id?.trim(); + final key = id != null && id.isNotEmpty + ? 'skin:$id' + : 'skin:path:${_pathDigest(skin.path)}'; + final label = skin.name.trim().isEmpty ? 'Custom skin' : skin.name.trim(); + return _ConsentSubject(key, label); + } + + if (callerId.startsWith('plugin:')) { + final id = callerId.substring(7); + if (id.trim().isEmpty) return null; + return _ConsentSubject(callerId, 'Plugin "$id"'); + } + if (callerId.startsWith('api:')) { + final id = callerId.substring(4); + if (id.trim().isEmpty) return null; + return _ConsentSubject(callerId, 'API client "$id"'); + } + return null; + } + + String _pathDigest(String path) => sha256 + .convert(utf8.encode(p.normalize(p.absolute(path.trim())))) + .toString(); +} + +class _ConsentSubject { + final String key; + final String label; + + const _ConsentSubject(this.key, this.label); +} diff --git a/lib/src/services/account/account_consent_store.dart b/lib/src/services/account/account_consent_store.dart new file mode 100644 index 000000000..5769f4c07 --- /dev/null +++ b/lib/src/services/account/account_consent_store.dart @@ -0,0 +1,96 @@ +import 'dart:convert'; + +import 'package:reaprime/src/services/account/decent_account_service.dart' + show CredentialStore; + +enum AccountConsentDecision { allowed, denied } + +class AccountConsentStore { + final CredentialStore _credentialStore; + final String _storageKey; + + Map? _decisions; + Future? _loading; + Future _writeTail = Future.value(); + + AccountConsentStore({ + required CredentialStore credentialStore, + String storageKey = 'account_proxy_consent', + }) : _credentialStore = credentialStore, + _storageKey = storageKey; + + Future read(String key) async { + await _ensureLoaded(); + return _decisions![key]; + } + + Future write(String key, AccountConsentDecision decision) { + return _enqueue(() async { + await _ensureLoaded(); + final updated = {..._decisions!, key: decision}; + await _persist(updated); + _decisions = updated; + }); + } + + Future _ensureLoaded() async { + if (_decisions != null) return; + final loading = _loading; + if (loading != null) return loading; + + final next = _load(); + _loading = next; + try { + await next; + } finally { + if (identical(_loading, next)) _loading = null; + } + } + + Future _load() async { + final raw = await _credentialStore.read(key: _storageKey); + if (raw == null || raw.isEmpty) { + _decisions = const {}; + return; + } + + try { + final decoded = jsonDecode(raw); + if (decoded is! Map) { + _decisions = const {}; + return; + } + final loaded = {}; + for (final entry in decoded.entries) { + if (entry.key is! String || entry.value is! String) continue; + final decision = switch (entry.value as String) { + 'allowed' => AccountConsentDecision.allowed, + 'denied' => AccountConsentDecision.denied, + _ => null, + }; + if (decision != null) loaded[entry.key as String] = decision; + } + _decisions = loaded; + } on FormatException { + _decisions = const {}; + } + } + + Future _persist(Map decisions) { + return _credentialStore.write( + key: _storageKey, + value: jsonEncode( + decisions.map((key, decision) => MapEntry(key, decision.name)), + ), + ); + } + + Future _enqueue(Future Function() operation) { + final result = _writeTail.then((_) => operation()); + _writeTail = result.then( + (_) {}, + onError: (Object _, StackTrace _) {}, + ); + return result; + } +} diff --git a/lib/src/services/account/decent_proxy_service.dart b/lib/src/services/account/decent_proxy_service.dart index 39ff787d4..2f7d43de6 100644 --- a/lib/src/services/account/decent_proxy_service.dart +++ b/lib/src/services/account/decent_proxy_service.dart @@ -5,6 +5,8 @@ import 'package:logging/logging.dart'; import 'package:reaprime/src/services/account/decent_account_service.dart' show CredentialStore; +typedef RequireAccountConsent = Future Function(String callerId); + class DecentAccountNotLinkedException implements Exception { @override String toString() => 'DecentAccountNotLinkedException: no account linked'; @@ -18,6 +20,11 @@ class DecentProxyForbiddenPathException implements Exception { 'DecentProxyForbiddenPathException: path not allowed: $path'; } +class DecentProxyConsentDeniedException implements Exception { + @override + String toString() => 'DecentProxyConsentDeniedException'; +} + class DecentProxyResponse { final int statusCode; final Map headers; @@ -35,6 +42,7 @@ class DecentProxyResponse { class DecentProxyService { final http.Client _httpClient; final CredentialStore _store; + final RequireAccountConsent _requireConsent; final String baseUrl; final Set allowedPrefixes; @@ -56,10 +64,12 @@ class DecentProxyService { DecentProxyService({ required http.Client httpClient, required CredentialStore credentialStore, + required RequireAccountConsent requireConsent, this.baseUrl = 'https://decentespresso.com', this.allowedPrefixes = const {'support/api/'}, }) : _httpClient = httpClient, - _store = credentialStore; + _store = credentialStore, + _requireConsent = requireConsent; Future proxy({ required String callerId, @@ -86,12 +96,18 @@ class DecentProxyService { throw DecentProxyForbiddenPathException(normalizedPath); } - final email = await _store.read(key: 'email'); - final password = await _store.read(key: 'password'); - if (email == null || password == null) { + if (await _credentials() == null) { throw DecentAccountNotLinkedException(); } + if (!await _requireConsent(callerId)) { + _log.warning('caller=$callerId -> account consent denied'); + throw DecentProxyConsentDeniedException(); + } + + final credentials = await _credentials(); + if (credentials == null) throw DecentAccountNotLinkedException(); + final uri = _buildUri( normalizedPath, rawQuery: rawQuery, @@ -105,7 +121,7 @@ class DecentProxyService { } final basic = base64Encode( - utf8.encode('${email.trim()}:${password.trim()}'), + utf8.encode('${credentials.email}:${credentials.password}'), ); final outbound = http.Request(normalizedMethod, uri) ..headers['authorization'] = 'Basic $basic'; @@ -132,6 +148,13 @@ class DecentProxyService { ); } + Future<({String email, String password})?> _credentials() async { + final email = await _store.read(key: 'email'); + final password = await _store.read(key: 'password'); + if (email == null || password == null) return null; + return (email: email.trim(), password: password.trim()); + } + Future proxyGet({ required String callerId, required String path, diff --git a/lib/src/services/webserver/account_proxy_handler.dart b/lib/src/services/webserver/account_proxy_handler.dart index f24003bef..aa54f548b 100644 --- a/lib/src/services/webserver/account_proxy_handler.dart +++ b/lib/src/services/webserver/account_proxy_handler.dart @@ -36,6 +36,8 @@ class AccountProxyHandler { return jsonUnauthorized({'error': 'Decent account not linked'}); } on DecentProxyForbiddenPathException { return jsonForbidden({'error': 'Path not allowed'}); + } on DecentProxyConsentDeniedException { + return jsonForbidden({'error': 'Account access was not granted'}); } } @@ -77,6 +79,8 @@ class AccountProxyHandler { return jsonUnauthorized({'error': 'Decent account not linked'}); } on DecentProxyForbiddenPathException { return jsonForbidden({'error': 'Path not allowed'}); + } on DecentProxyConsentDeniedException { + return jsonForbidden({'error': 'Account access was not granted'}); } } } diff --git a/test/account/account_consent_prompter_test.dart b/test/account/account_consent_prompter_test.dart new file mode 100644 index 000000000..14dca7b2c --- /dev/null +++ b/test/account/account_consent_prompter_test.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:reaprime/src/account/account_consent_prompter.dart'; +import 'package:reaprime/src/services/account/account_consent_store.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +void main() { + late GlobalKey navigatorKey; + + setUp(() { + navigatorKey = GlobalKey(); + }); + + Future pumpApp(WidgetTester tester) { + return tester.pumpWidget( + ShadApp( + navigatorKey: navigatorKey, + home: const Scaffold(body: SizedBox.expand(child: Text('Skin view'))), + ), + ); + } + + testWidgets('allows from a trusted native dialog over the active view', ( + tester, + ) async { + await pumpApp(tester); + final prompter = AccountConsentPrompter(navigatorKey: navigatorKey); + + final result = prompter.prompt('Aileen'); + await tester.pumpAndSettle(); + + expect(find.text('Skin view'), findsOneWidget); + expect(find.text('Decent account access'), findsOneWidget); + expect( + find.text('Aileen wants to use your linked Decent account.'), + findsOneWidget, + ); + await tester.tap(find.text('Allow')); + await tester.pumpAndSettle(); + + expect(await result, AccountConsentDecision.allowed); + }); + + testWidgets('returns an explicit denial', (tester) async { + await pumpApp(tester); + final prompter = AccountConsentPrompter(navigatorKey: navigatorKey); + + final result = prompter.prompt('Plugin "dye2"'); + await tester.pumpAndSettle(); + await tester.tap(find.text('Deny')); + await tester.pumpAndSettle(); + + expect(await result, AccountConsentDecision.denied); + }); + + testWidgets('timeout closes the prompt without a persisted decision', ( + tester, + ) async { + await pumpApp(tester); + final prompter = AccountConsentPrompter( + navigatorKey: navigatorKey, + timeout: const Duration(milliseconds: 20), + ); + + final result = prompter.prompt('Aileen'); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 20)); + await tester.pumpAndSettle(); + + expect(await result, isNull); + expect(find.text('Decent account access'), findsNothing); + }); + + test('no attached navigator denies without showing UI', () async { + final prompter = AccountConsentPrompter(navigatorKey: navigatorKey); + + expect(await prompter.prompt('Aileen'), isNull); + }); +} diff --git a/test/cli_options_test.dart b/test/cli_options_test.dart index 8a2cacc66..7edc8494e 100644 --- a/test/cli_options_test.dart +++ b/test/cli_options_test.dart @@ -9,6 +9,8 @@ void main() { expect(args.bypassOnboarding, isFalse); expect(args.direct, isFalse); expect(args.noAccount, isFalse); + expect(args.trustedConsentKeys, isEmpty); + expect(args.trustAllConsent, isFalse); expect(args.skinId, isNull); expect(args.skinPath, isNull); }); @@ -48,12 +50,28 @@ void main() { expect(args.printStoragePaths, isTrue); }); + test('--trust-consent is repeatable', () { + final args = parseCliArgs([ + '--trust-consent=skin:aileen', + '--trust-consent=plugin:dye2', + ]); + + expect(args.trustedConsentKeys, {'skin:aileen', 'plugin:dye2'}); + }); + + test('--trust-all-consent', () { + final args = parseCliArgs(['--trust-all-consent']); + expect(args.trustAllConsent, isTrue); + }); + test('all flags combined', () { final args = parseCliArgs([ '--serial', '--bypass-onboarding', '--direct', '--no-account', + '--trust-consent=skin:streamline.js', + '--trust-all-consent', '--skin=streamline.js', '--skin-path=/tmp/test-skin', ]); @@ -61,6 +79,8 @@ void main() { expect(args.bypassOnboarding, isTrue); expect(args.direct, isTrue); expect(args.noAccount, isTrue); + expect(args.trustedConsentKeys, {'skin:streamline.js'}); + expect(args.trustAllConsent, isTrue); expect(args.skinId, 'streamline.js'); expect(args.skinPath, '/tmp/test-skin'); }); diff --git a/test/plugins/plugin_decent_proxy_bridge_test.dart b/test/plugins/plugin_decent_proxy_bridge_test.dart index 5cd5381ae..469527601 100644 --- a/test/plugins/plugin_decent_proxy_bridge_test.dart +++ b/test/plugins/plugin_decent_proxy_bridge_test.dart @@ -26,6 +26,8 @@ class FakeCredentialStore implements CredentialStore { } } +Future _allowConsent(String _) async => true; + void main() { late FakeCredentialStore store; @@ -52,11 +54,15 @@ void main() { ); } - PluginDecentProxyBridge bridge(http_testing.MockClientHandler handler) { + PluginDecentProxyBridge bridge( + http_testing.MockClientHandler handler, { + Future Function(String callerId) requireConsent = _allowConsent, + }) { return PluginDecentProxyBridge( decentProxyService: DecentProxyService( httpClient: http_testing.MockClient(handler), credentialStore: store, + requireConsent: requireConsent, ), ); } @@ -100,6 +106,33 @@ void main() { ); }); + test('plugin consent denial stops before the upstream request', () async { + await linkAccount(); + String? consentCaller; + var upstreamCalled = false; + + await expectLater( + bridge( + (request) async { + upstreamCalled = true; + return http.Response('must not happen', 200); + }, + requireConsent: (callerId) async { + consentCaller = callerId; + return false; + }, + ).proxyForPlugin( + pluginId: 'test.plugin', + manifest: manifestWith({PluginPermissions.proxyDecentApi}), + path: 'support/api/sn', + ), + throwsA(isA()), + ); + + expect(consentCaller, 'plugin:test.plugin'); + expect(upstreamCalled, isFalse); + }); + test( 'declared permission forwards POST with body through DecentProxyService', () async { diff --git a/test/plugins/plugin_manager_decent_proxy_bridge_test.dart b/test/plugins/plugin_manager_decent_proxy_bridge_test.dart index 47db2985b..92883b858 100644 --- a/test/plugins/plugin_manager_decent_proxy_bridge_test.dart +++ b/test/plugins/plugin_manager_decent_proxy_bridge_test.dart @@ -101,6 +101,7 @@ void main() { kvStore: FakeKeyValueStoreService(), decentProxyService: DecentProxyService( credentialStore: credentialStore, + requireConsent: (_) async => true, httpClient: http_testing.MockClient((request) async { upstreamCalls += 1; return http.Response( diff --git a/test/plugins/plugin_manager_decent_proxy_ownership_test.dart b/test/plugins/plugin_manager_decent_proxy_ownership_test.dart index cc2ed7b8c..85a2734ee 100644 --- a/test/plugins/plugin_manager_decent_proxy_ownership_test.dart +++ b/test/plugins/plugin_manager_decent_proxy_ownership_test.dart @@ -59,6 +59,7 @@ void main() { decentProxyTimeout: decentProxyTimeout, decentProxyService: DecentProxyService( credentialStore: store, + requireConsent: (_) async => true, httpClient: http_testing.MockClient(onRequest), ), ); diff --git a/test/plugins/plugin_manager_workload_test.dart b/test/plugins/plugin_manager_workload_test.dart index 9d8a89e8a..3b747061a 100644 --- a/test/plugins/plugin_manager_workload_test.dart +++ b/test/plugins/plugin_manager_workload_test.dart @@ -51,6 +51,7 @@ void main() { decentProxyTimeout: const Duration(seconds: 5), decentProxyService: DecentProxyService( credentialStore: store, + requireConsent: (_) async => true, httpClient: http_testing.MockClient( (request) async => http.Response('{"serial":"SN001"}', 200), ), diff --git a/test/plugins/shot_upload_plugin_test.dart b/test/plugins/shot_upload_plugin_test.dart index 27c158956..9cd52d94a 100644 --- a/test/plugins/shot_upload_plugin_test.dart +++ b/test/plugins/shot_upload_plugin_test.dart @@ -132,6 +132,7 @@ void main() { kvStore: _FakeKeyValueStore(), decentProxyService: DecentProxyService( credentialStore: store, + requireConsent: (_) async => true, httpClient: http_testing.MockClient((request) async { captured.add(request); return http.Response( diff --git a/test/services/account/account_consent_gate_test.dart b/test/services/account/account_consent_gate_test.dart new file mode 100644 index 000000000..0ca1c4124 --- /dev/null +++ b/test/services/account/account_consent_gate_test.dart @@ -0,0 +1,186 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:reaprime/src/services/account/account_consent_gate.dart'; +import 'package:reaprime/src/services/account/account_consent_store.dart'; +import 'package:reaprime/src/services/account/decent_account_service.dart' + show CredentialStore; + +class _CredentialStore implements CredentialStore { + final Map values = {}; + + @override + Future read({required String key}) async => values[key]; + + @override + Future write({required String key, required String value}) async { + values[key] = value; + } + + @override + Future delete({required String key}) async { + values.remove(key); + } +} + +void main() { + late _CredentialStore credentials; + late AccountConsentStore store; + late ActiveSkinConsent? activeSkin; + late List prompts; + + setUp(() { + credentials = _CredentialStore(); + store = AccountConsentStore(credentialStore: credentials); + activeSkin = const ActiveSkinConsent( + id: 'aileen', + name: 'Aileen', + path: '/skins/aileen', + ); + prompts = []; + }); + + AccountConsentGate gate({ + Future Function(String label)? prompt, + Set trustedConsentKeys = const {}, + bool trustAllConsent = false, + }) => AccountConsentGate( + store: store, + activeSkin: () => activeSkin, + prompt: + prompt ?? + (label) async { + prompts.add(label); + return AccountConsentDecision.allowed; + }, + trustedConsentKeys: trustedConsentKeys, + trustAllConsent: trustAllConsent, + ); + + test('prompts once and remembers an installed skin allow', () async { + final consent = gate(); + + expect(await consent.requireConsent('skin'), isTrue); + expect(await consent.requireConsent('skin'), isTrue); + expect(prompts, ['Aileen']); + expect(await store.read('skin:aileen'), AccountConsentDecision.allowed); + }); + + test('remembers an explicit deny', () async { + final consent = gate( + prompt: (label) async { + prompts.add(label); + return AccountConsentDecision.denied; + }, + ); + + expect(await consent.requireConsent('api:laptop'), isFalse); + expect(await consent.requireConsent('api:laptop'), isFalse); + expect(prompts, ['API client "laptop"']); + expect(await store.read('api:laptop'), AccountConsentDecision.denied); + }); + + test('a different skin id cannot inherit consent', () async { + final consent = gate(); + + expect(await consent.requireConsent('skin'), isTrue); + activeSkin = const ActiveSkinConsent( + id: 'streamline', + name: 'Streamline', + path: '/skins/streamline', + ); + expect(await consent.requireConsent('skin'), isTrue); + + expect(prompts, ['Aileen', 'Streamline']); + }); + + test('a custom skin path is hashed instead of persisted', () async { + activeSkin = const ActiveSkinConsent( + name: 'Custom skin', + path: r'C:\Users\rea\private-skin', + ); + + expect(await gate().requireConsent('skin'), isTrue); + + final persisted = credentials.values['account_proxy_consent']!; + expect(persisted, contains('skin:path:')); + expect(persisted, isNot(contains('private-skin'))); + }); + + test('concurrent requests for one caller share one prompt', () async { + final decision = Completer(); + var promptCount = 0; + final consent = gate( + prompt: (_) { + promptCount++; + return decision.future; + }, + ); + + final first = consent.requireConsent('plugin:dye2'); + final second = consent.requireConsent('plugin:dye2'); + await Future.delayed(Duration.zero); + expect(promptCount, 1); + + decision.complete(AccountConsentDecision.allowed); + expect(await Future.wait([first, second]), [isTrue, isTrue]); + }); + + test('timeout denial is not persisted', () async { + final consent = gate( + prompt: (label) async { + prompts.add(label); + return null; + }, + ); + + expect(await consent.requireConsent('plugin:dye2'), isFalse); + expect(await consent.requireConsent('plugin:dye2'), isFalse); + expect(prompts, ['Plugin "dye2"', 'Plugin "dye2"']); + expect(await store.read('plugin:dye2'), isNull); + }); + + test('headless skin request denies without a wildcard decision', () async { + activeSkin = null; + + expect(await gate().requireConsent('skin'), isFalse); + expect(prompts, isEmpty); + }); + + test('trust-all permits a headless skin caller', () async { + activeSkin = null; + + expect(await gate(trustAllConsent: true).requireConsent('skin'), isTrue); + expect(prompts, isEmpty); + }); + + test('rejects unsupported and empty caller identities', () async { + final consent = gate(trustAllConsent: true); + + expect(await consent.requireConsent('unknown'), isFalse); + expect(await consent.requireConsent('plugin:'), isFalse); + expect(await consent.requireConsent('api:'), isFalse); + expect(prompts, isEmpty); + }); + + test('session trust takes precedence over a persisted deny', () async { + await store.write('api:station', AccountConsentDecision.denied); + + expect( + await gate( + trustedConsentKeys: {'api:station'}, + ).requireConsent('api:station'), + isTrue, + ); + expect(prompts, isEmpty); + }); + + test('trust-all permits a caller without prompting or persistence', () async { + expect( + await gate(trustAllConsent: true).requireConsent('plugin:dye2'), + isTrue, + ); + expect(prompts, isEmpty); + expect(await store.read('plugin:dye2'), isNull); + }); +} diff --git a/test/services/account/account_consent_store_test.dart b/test/services/account/account_consent_store_test.dart new file mode 100644 index 000000000..de02ada02 --- /dev/null +++ b/test/services/account/account_consent_store_test.dart @@ -0,0 +1,50 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:reaprime/src/services/account/account_consent_store.dart'; +import 'package:reaprime/src/services/account/decent_account_service.dart' + show CredentialStore; + +class _CredentialStore implements CredentialStore { + final Map values = {}; + + @override + Future read({required String key}) async => values[key]; + + @override + Future write({required String key, required String value}) async { + values[key] = value; + } + + @override + Future delete({required String key}) async { + values.remove(key); + } +} + +void main() { + late _CredentialStore credentials; + late AccountConsentStore store; + + setUp(() { + credentials = _CredentialStore(); + store = AccountConsentStore(credentialStore: credentials); + }); + + test('returns no decision for an unknown caller', () async { + expect(await store.read('skin:aileen'), isNull); + }); + + test('allowed and denied decisions survive store recreation', () async { + await store.write('skin:aileen', AccountConsentDecision.allowed); + await store.write('api:laptop', AccountConsentDecision.denied); + + final reloaded = AccountConsentStore(credentialStore: credentials); + expect(await reloaded.read('skin:aileen'), AccountConsentDecision.allowed); + expect(await reloaded.read('api:laptop'), AccountConsentDecision.denied); + }); + + test('malformed persisted data fails closed', () async { + credentials.values['account_proxy_consent'] = '{broken'; + + expect(await store.read('skin:aileen'), isNull); + }); +} diff --git a/test/services/account/decent_proxy_service_test.dart b/test/services/account/decent_proxy_service_test.dart index 2577bf8f9..d4c0c6e01 100644 --- a/test/services/account/decent_proxy_service_test.dart +++ b/test/services/account/decent_proxy_service_test.dart @@ -24,6 +24,8 @@ class FakeCredentialStore implements CredentialStore { } } +Future _allowConsent(String _) async => true; + void main() { late FakeCredentialStore store; @@ -39,10 +41,12 @@ void main() { DecentProxyService buildService( http_testing.MockClientHandler handler, { String baseUrl = 'https://decentespresso.com', + Future Function(String callerId) requireConsent = _allowConsent, }) { return DecentProxyService( httpClient: http_testing.MockClient(handler), credentialStore: store, + requireConsent: requireConsent, baseUrl: baseUrl, ); } @@ -58,6 +62,26 @@ void main() { ); }); + test('denied consent never reaches the upstream service', () async { + await linkAccount(); + String? consentCaller; + final service = buildService( + (request) async { + fail('must not call upstream when consent is denied: ${request.url}'); + }, + requireConsent: (callerId) async { + consentCaller = callerId; + return false; + }, + ); + + await expectLater( + service.proxyGet(callerId: 'plugin:dye2', path: 'support/api/sn'), + throwsA(isA()), + ); + expect(consentCaller, 'plugin:dye2'); + }); + test('attaches Basic auth and relays the upstream body + status', () async { await linkAccount(); late http.Request captured; diff --git a/test/webserver/account_proxy_handler_test.dart b/test/webserver/account_proxy_handler_test.dart index e6e9aaacf..f01e16235 100644 --- a/test/webserver/account_proxy_handler_test.dart +++ b/test/webserver/account_proxy_handler_test.dart @@ -33,6 +33,7 @@ void main() { upstream = null; final proxy = DecentProxyService( + requireConsent: (_) async => true, httpClient: http_testing.MockClient((request) async { upstream = request; return http.Response('SN001\nSN002', 200); @@ -136,10 +137,39 @@ void main() { }, ); + test('authenticated caller denied consent receives 403', () async { + await linkAccount(); + final app = Router().plus; + final proxy = DecentProxyService( + requireConsent: (_) async => false, + httpClient: http_testing.MockClient((request) async { + fail('must not call upstream when consent is denied: ${request.url}'); + }), + credentialStore: store, + ); + AccountProxyHandler(proxy: proxy).addRoutes(app); + handler = const Pipeline() + .addMiddleware(proxyAuthMiddleware(tokens)) + .addHandler(app.call); + + final response = await get( + '/api/v1/account/proxy/support/api/sn', + token: 'skin-token', + ); + + expect(response.statusCode, 403); + expect( + jsonDecode(await response.readAsString())['error'], + 'Account access was not granted', + ); + expect(upstream, isNull); + }); + test('authenticated + linked relays upstream response bytes', () async { await linkAccount(); final app = Router().plus; final proxy = DecentProxyService( + requireConsent: (_) async => true, httpClient: http_testing.MockClient((request) async { upstream = request; return http.Response.bytes( @@ -237,6 +267,7 @@ void main() { registerWriteToken(); final app = Router().plus; final proxy = DecentProxyService( + requireConsent: (_) async => true, httpClient: http_testing.MockClient((request) async { upstream = request; return http.Response('SN001\nSN002', 200); @@ -272,6 +303,7 @@ void main() { registerWriteToken(); final app = Router().plus; final proxy = DecentProxyService( + requireConsent: (_) async => true, httpClient: http_testing.MockClient((request) async { upstream = request; return http.Response('SN001\nSN002', 200); @@ -301,6 +333,7 @@ void main() { registerWriteToken(); final app = Router().plus; final proxy = DecentProxyService( + requireConsent: (_) async => true, httpClient: http_testing.MockClient((request) async { upstream = request; return http.Response('SN001\nSN002', 200); From 71710e63fdf99565e6d4b59a02d4dde160222074 Mon Sep 17 00:00:00 2001 From: ODevStudio Date: Fri, 21 Aug 2026 10:19:10 +0200 Subject: [PATCH 2/4] fix(account): bind skin consent to served origin Rotate the skin token and browser origin for each served generation so stale pages cannot inherit another skin's consent. Remove timed-out consent dialogs by exact route and keep the port 3000 entry URL as a no-store redirect. --- .../scenarios/account-proxy-cors.md | 25 +++--- assets/api/rest_v1.yml | 3 +- doc/Api.md | 2 +- doc/Skins.md | 6 +- .../archive/account-proxy-consent/design.md | 18 ++-- lib/main.dart | 34 ++++--- lib/src/account/account_consent_prompter.dart | 45 +++------- lib/src/app.dart | 1 + .../account/account_consent_gate.dart | 40 +++++---- .../services/account/proxy_token_service.dart | 13 ++- lib/src/skin_feature/skin_view.dart | 22 +++-- lib/src/webui_support/webui_service.dart | 72 +++++++++++++-- .../account_consent_prompter_test.dart | 19 ++++ .../account/account_consent_gate_test.dart | 39 ++++---- .../account/proxy_token_service_test.dart | 30 +++++++ .../skin_feature/skin_navigation_test.dart | 51 +++++++---- .../skin_view_brightness_test.dart | 1 + .../webui_token_injection_test.dart | 89 ++++++++++++++++--- 18 files changed, 361 insertions(+), 149 deletions(-) diff --git a/.agents/skills/decent-app/scenarios/account-proxy-cors.md b/.agents/skills/decent-app/scenarios/account-proxy-cors.md index 627e79436..a2af74dbc 100644 --- a/.agents/skills/decent-app/scenarios/account-proxy-cors.md +++ b/.agents/skills/decent-app/scenarios/account-proxy-cors.md @@ -1,9 +1,10 @@ # Scenario: Account-proxy CORS pinned to skin origin Verifies the defense-in-depth CORS hardening (#301): on `/api/v1/account/proxy/*` -the `Access-Control-Allow-Origin` is **pinned** to the known skin origin(s) -(loopback + the device LAN IP, on the skin port `:3000`) instead of the global -permissive value. Non-proxy API paths keep their existing permissive CORS. +the `Access-Control-Allow-Origin` is **pinned** to the active skin origin(s) +(loopback + the device LAN IP, on the current per-generation skin port) instead +of the global permissive value. Non-proxy API paths keep their existing +permissive CORS. The CORS headers are applied by an outer middleware that post-processes every response on the proxy path, so the behaviour is observable **without a valid proxy @@ -15,15 +16,17 @@ token** — an unauthenticated `401` on the proxy path still carries the pinned ```bash scripts/sb-dev.sh start --platform macos --connect-machine MockDe1 P=/api/v1/account/proxy/support/api/sn +SKIN_PORT=$(curl -sf http://localhost:8080/api/v1/webui/server/status | jq -r '.port') +SKIN_ORIGIN="http://localhost:$SKIN_PORT" ``` ## Steps ```bash # 1. Allowed skin origin -> ACAO echoes that origin (not '*') + Vary: Origin -curl -s -D - -o /dev/null -H "Origin: http://localhost:3000" \ +curl -s -D - -o /dev/null -H "Origin: $SKIN_ORIGIN" \ "http://localhost:8080$P" | grep -iE "access-control-allow-origin|^vary" -# -> access-control-allow-origin: http://localhost:3000 +# -> access-control-allow-origin: $SKIN_ORIGIN # -> vary: Origin # 2. Disallowed origin on the proxy path -> NO permissive ACAO at all @@ -38,9 +41,9 @@ curl -s -D - -o /dev/null -H "Origin: http://evil.example:3000" \ # 4. OPTIONS preflight follows the same rule curl -s -D - -o /dev/null -X OPTIONS \ - -H "Origin: http://localhost:3000" -H "Access-Control-Request-Method: GET" \ + -H "Origin: $SKIN_ORIGIN" -H "Access-Control-Request-Method: GET" \ "http://localhost:8080$P" | grep -iE "access-control-allow-origin|^vary" -# -> access-control-allow-origin: http://localhost:3000 ; vary: Origin +# -> access-control-allow-origin: $SKIN_ORIGIN ; vary: Origin curl -s -D - -o /dev/null -X OPTIONS \ -H "Origin: http://evil.example:3000" -H "Access-Control-Request-Method: GET" \ "http://localhost:8080$P" | grep -iE "access-control-allow-origin" \ @@ -50,17 +53,17 @@ curl -s -D - -o /dev/null -X OPTIONS \ One-shot assertion: ```bash -allowed=$(curl -s -D - -o /dev/null -H "Origin: http://localhost:3000" "http://localhost:8080$P" \ +allowed=$(curl -s -D - -o /dev/null -H "Origin: $SKIN_ORIGIN" "http://localhost:8080$P" \ | awk 'BEGIN{IGNORECASE=1}/access-control-allow-origin:/{print $2}' | tr -d '\r') denied=$(curl -s -D - -o /dev/null -H "Origin: http://evil.example:3000" "http://localhost:8080$P" \ | awk 'BEGIN{IGNORECASE=1}/access-control-allow-origin:/{print $2}' | tr -d '\r') -test "$allowed" = "http://localhost:3000" || { echo "FAIL allowed: '$allowed'"; exit 1; } +test "$allowed" = "$SKIN_ORIGIN" || { echo "FAIL allowed: '$allowed'"; exit 1; } test -z "$denied" || { echo "FAIL denied leaked: '$denied'"; exit 1; } echo OK ``` -The device LAN-IP origin (`http://:3000`) is also allowed — the allowlist -is rebuilt per request, so an IP learned after startup works. Loopback variants +The device LAN-IP origin (`http://:`) is also allowed. The +allowlist is rebuilt per request, so an IP learned after startup works. Loopback variants (`127.0.0.1`, `[::1]`) are included. mDNS/`*.local` hostnames are intentionally not in the allowlist (open question carried from the design doc). diff --git a/assets/api/rest_v1.yml b/assets/api/rest_v1.yml index 6e1fcf065..7ba318fff 100644 --- a/assets/api/rest_v1.yml +++ b/assets/api/rest_v1.yml @@ -3927,6 +3927,7 @@ paths: port: type: integer nullable: true + description: Active per-generation skin origin port ip: type: string nullable: true @@ -3934,7 +3935,7 @@ paths: /api/v1/webui/server/start: post: summary: Start the WebUI server - description: Starts serving the default WebUI skin + description: Starts serving the default WebUI skin through the stable port 3000 entry point tags: [WebUI] responses: "200": diff --git a/doc/Api.md b/doc/Api.md index dd0619913..d61a1c71b 100644 --- a/doc/Api.md +++ b/doc/Api.md @@ -450,7 +450,7 @@ archive is also bounded by the 2 GiB import request limit. Linking/unlinking a Decent account is **native-only** — there are no network login/logout routes. The webserver is unauthenticated with `Access-Control-Allow-Origin: *`, so exposing credential operations would let any LAN client or browser origin store attacker credentials or unlink the account. The status response omits the linked email (PII). -The **proxy** lets clients *use* the account without ever seeing the credentials: it attaches the linked account's Basic auth server-side, forwards to `decentespresso.com`, and relays the upstream status + body verbatim. It requires `Authorization: Bearer ` and is enforced only on this path. `GET` requires `account:proxy` (including the skin token injected into served skin pages); `POST`/`PUT` require the stronger `account:proxy:write` scope, so the read-only skin token cannot write. Forwarding is restricted to the `support/api/` prefix. The OpenAPI spec documents the generated-client-safe `/support/api/{endpoint}` form; use this raw catch-all route when a Decent backend path contains additional slashes. The first request from each skin, plugin, or named API client pauses for native approval on the Decaid device. Explicit allow and deny decisions are remembered; a 30-second timeout denies only that request. Responses: 401 (missing/invalid token or no linked account), 403 (token unscoped, path not allowed, or account access not granted). Write-scoped tokens are minted from the account page's API-token UI by enabling "Allow write access". Headless operators can grant session-only access with `--trust-consent=` or `--trust-all-consent`. +The **proxy** lets clients *use* the account without ever seeing the credentials: it attaches the linked account's Basic auth server-side, forwards to `decentespresso.com`, and relays the upstream status + body verbatim. It requires `Authorization: Bearer ` and is enforced only on this path. `GET` requires `account:proxy` (including the skin token injected into served skin pages); `POST`/`PUT` require the stronger `account:proxy:write` scope, so the read-only skin token cannot write. Forwarding is restricted to the `support/api/` prefix. The OpenAPI spec documents the generated-client-safe `/support/api/{endpoint}` form; use this raw catch-all route when a Decent backend path contains additional slashes. Each served skin generation gets a fresh origin and token bound to that skin's immutable consent key; switching or stopping the skin server revokes the previous token. The stable port 3000 entry point redirects without caching to the active origin. The first request from each skin, plugin, or named API client pauses for native approval on the Decaid device. Explicit allow and deny decisions are remembered; a 30-second timeout denies only that request. Responses: 401 (missing/invalid token or no linked account), 403 (token unscoped, path not allowed, or account access not granted). Write-scoped tokens are minted from the account page's API-token UI by enabling "Allow write access". Headless operators can grant session-only access with `--trust-consent=` or `--trust-all-consent`. ### Other diff --git a/doc/Skins.md b/doc/Skins.md index ceb11751b..0c4ddca5a 100644 --- a/doc/Skins.md +++ b/doc/Skins.md @@ -3316,9 +3316,9 @@ http://:8080/api/v1/plugins/settings.reaplugin/ui?backName=MySkin This shows "Back to MySkin" in the settings plugin's nav bar. When clicked, it navigates to `http://:3000/?_=` (with cache busting). This allows skins to provide a "Settings" link that returns to the skin after configuration changes. -**External links:** When a skin runs inside the embedded webview (mobile/desktop app), navigations to `localhost:3000` and the settings plugin load in place; any other `http`/`https` link opens in the **system browser** while the skin stays loaded. A plain `` works, but the in-app webview blocks `target="_blank"` popups (`javaScriptCanOpenWindowsAutomatically: false`), so for JS-driven links route through a delegated click handler — `window.open(url, '_blank')` with a `location.href` fallback — so the navigation reaches `shouldOverrideUrlLoading` and is handed off to the OS. +**External links:** When a skin runs inside the embedded webview (mobile/desktop app), navigations to the active skin origin, `localhost:3000`, and the settings plugin load in place; any other `http`/`https` link opens in the **system browser** while the skin stays loaded. A plain `` works, but the in-app webview blocks `target="_blank"` popups (`javaScriptCanOpenWindowsAutomatically: false`). For JS-driven links, use a delegated click handler with `window.open(url, '_blank')` and a `location.href` fallback so the navigation reaches `shouldOverrideUrlLoading` and is handed off to the OS. -**Return to the dashboard:** Skin pages served on port 3000 load the tokenless `/__decent/skin-api.js` from an absolute same-origin URL, which exposes `window.decentApp.exitToDashboard()`. ReaPrime stores the account-proxy token in escaped page metadata that only the same-origin script reads. Token injection accepts loopback and IP addresses currently assigned to the device, including Ethernet and secondary adapters; arbitrary hostnames and stale addresses are rejected. If local interface enumeration is unavailable, the WiFi address cached for the server link is used as a fallback. The script response also uses `Cross-Origin-Resource-Policy: same-origin`. In the embedded webview the callback closes the skin and reveals the Decent dashboard. In an external browser it is a no-op. The script works with `script-src 'self'`; policies that reject all same-origin scripts, such as `script-src 'none'` or nonce-only policies without `'self'`, also reject this API. +**Return to the dashboard:** Port 3000 is a stable no-store entry point that redirects to a fresh browser origin each time Decaid serves a skin. The active origin loads the tokenless `/__decent/skin-api.js` from an absolute same-origin URL, which exposes `window.decentApp.exitToDashboard()`. ReaPrime stores a newly rotated, skin-bound account-proxy token in escaped page metadata that only the same-origin script reads; switching or stopping the server revokes it. This prevents a stale skin tab from reading or using the next skin's token. Token injection accepts loopback and IP addresses currently assigned to the device, including Ethernet and secondary adapters; arbitrary hostnames and stale addresses are rejected. If local interface enumeration is unavailable, the WiFi address cached for the server link is used as a fallback. The script response also uses `Cross-Origin-Resource-Policy: same-origin`. In the embedded webview the callback closes the skin and reveals the Decent dashboard. In an external browser it is a no-op. The script works with `script-src 'self'`; policies that reject all same-origin scripts, such as `script-src 'none'` or nonce-only policies without `'self'`, also reject this API. The embedded webview also shows a platform-specific navigation guide when a skin opens. On Windows, choose **Back to Dashboard** from the system menu, available from the window icon or by right-clicking the title bar. Disable or restore the guide in **Settings** under **General** with **Skin navigation guide**. @@ -3327,7 +3327,7 @@ The embedded webview also shows a platform-specific navigation guide when a skin | Method | Path | Description | |--------|------|-------------| | GET | `/api/v1/webui/server/status` | Current status (`{serving, path, port, ip}`) | -| POST | `/api/v1/webui/server/start` | Start serving the default skin on port 3000 | +| POST | `/api/v1/webui/server/start` | Start serving the default skin through the port 3000 entry point | | POST | `/api/v1/webui/server/stop` | Stop serving | | POST | `/api/v1/webui/skins/update` | Check all remote skin sources for updates | diff --git a/doc/plans/archive/account-proxy-consent/design.md b/doc/plans/archive/account-proxy-consent/design.md index ba0c6de84..a05bdbd5d 100644 --- a/doc/plans/archive/account-proxy-consent/design.md +++ b/doc/plans/archive/account-proxy-consent/design.md @@ -10,20 +10,26 @@ named API client can use the linked Decent account. - Keep bearer-token scope checks in `proxyAuthMiddleware`. - Enforce consent in `DecentProxyService`, the shared path used by HTTP clients and `host.decentProxy` plugins, before any upstream request. -- Resolve the generic skin caller to `skin:`. For an - unregistered served path, use `skin:path:`. If no - active skin can be identified, deny without prompting. +- Bind each served skin token to `skin:`. For an unregistered + served path, use `skin:path:`. If the served path + cannot be identified, do not inject an account-proxy token. +- Give every served skin generation a fresh token and browser origin. Port 3000 + remains a no-store redirect to the active origin, so existing entry links + continue to work without allowing stale pages to read the next token. Revoke + the token when the skin server stops. - Persist explicit allow and deny decisions in the existing credential store. A timeout or unavailable navigator denies without persistence. - Coalesce concurrent requests for one caller onto one prompt. - A 30-second native dialog timeout defaults to deny. The dialog cannot be - dismissed by tapping outside it. + dismissed by tapping outside it, and each timeout removes its own route so + concurrent prompts cannot dismiss one another. - CLI trust keys are session-only and evaluated before persisted decisions. ## Verification -1. Unit-test storage round trips, caller identity, remembered decisions, - coalescing, timeout behavior, headless denial, and CLI trust. +1. Unit-test storage round trips, immutable caller identity, token and origin + rotation, remembered decisions, coalescing, concurrent timeout behavior, + headless denial, and CLI trust. 2. Prove the shared proxy service neither forwards nor exposes credentials when consent is denied, including the plugin caller path. 3. Widget-test allow, deny, timeout, and rendering over the active full-screen diff --git a/lib/main.dart b/lib/main.dart index e79be99e7..c059513a2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -159,20 +159,17 @@ Future _printStoragePaths() async { exit(0); } -ActiveSkinConsent? _activeSkinConsent( - WebUIService service, - WebUIStorage storage, -) { - final path = service.serverPath().trim(); - if (path.isEmpty) return null; - final normalizedPath = p.normalize(path); +ActiveSkinConsent? _activeSkinConsent(String path, WebUIStorage storage) { + final value = path.trim(); + if (value.isEmpty) return null; + final normalizedPath = p.normalize(value); final skin = storage.installedSkins.firstWhereOrNull( (candidate) => p.equals(p.normalize(candidate.path), normalizedPath), ); return ActiveSkinConsent( id: skin?.id, name: skin?.name ?? 'Custom skin', - path: path, + path: value, ); } @@ -416,6 +413,7 @@ void main(List args) async { DecentProxyService? decentProxyService; AccountTokensController? accountTokensController; CredentialStore? credentialStore; + AccountConsentGate? consentGate; final proxyTokenService = ProxyTokenService(); if (cliArgs.noAccount) { log.info('--no-account: skipping credential store and account service'); @@ -426,13 +424,13 @@ void main(List args) async { final consentPrompter = AccountConsentPrompter( navigatorKey: NavigationService.navigatorKey, ); - final consentGate = AccountConsentGate( + final gate = AccountConsentGate( store: AccountConsentStore(credentialStore: credentialStore), - activeSkin: () => _activeSkinConsent(webUIService, webUIStorage), prompt: consentPrompter.prompt, trustedConsentKeys: cliArgs.trustedConsentKeys, trustAllConsent: cliArgs.trustAllConsent, ); + consentGate = gate; const decentBaseUrl = String.fromEnvironment( 'DECENT_BASE_URL', defaultValue: 'https://decentespresso.com', @@ -445,7 +443,7 @@ void main(List args) async { decentProxyService = DecentProxyService( httpClient: http.Client(), credentialStore: credentialStore, - requireConsent: consentGate.requireConsent, + requireConsent: gate.requireConsent, baseUrl: decentBaseUrl, ); accountTokensController = AccountTokensController( @@ -454,7 +452,19 @@ void main(List args) async { ); await accountTokensController.initialize(); } - webUIService.skinProxyToken = proxyTokenService.skinToken; + webUIService.skinProxyTokenProvider = (path) { + final skin = _activeSkinConsent(path, webUIStorage); + final gate = consentGate; + if (skin == null || gate == null) return null; + gate.registerCallerLabel(skin.key, skin.name); + return proxyTokenService.rotateSkinToken( + ProxyCaller( + id: skin.key, + scopes: const {ProxyTokenService.scopeAccountProxy}, + ), + ); + }; + webUIService.skinProxyTokenRevoker = proxyTokenService.revokeSkinToken; final PluginLoaderService pluginService = PluginLoaderService( kvStore: HiveStoreService(defaultNamespace: "plugins")..initialize(), diff --git a/lib/src/account/account_consent_prompter.dart b/lib/src/account/account_consent_prompter.dart index 2a1e798d7..1bbcb7818 100644 --- a/lib/src/account/account_consent_prompter.dart +++ b/lib/src/account/account_consent_prompter.dart @@ -16,52 +16,31 @@ class AccountConsentPrompter { Future prompt(String callerLabel) { final context = _navigatorKey.currentContext; if (context == null || !context.mounted) return Future.value(); - - return showShadDialog( - context: context, + final navigator = _navigatorKey.currentState; + if (navigator == null) return Future.value(); + final route = ShadDialogRoute( + pageBuilder: (context) => _AccountConsentDialog(callerLabel: callerLabel), barrierDismissible: false, - builder: (context) => - _AccountConsentDialog(callerLabel: callerLabel, timeout: timeout), ); + final result = navigator.push(route); + final timer = Timer(timeout, () { + if (route.isActive && navigator.mounted) navigator.removeRoute(route); + }); + return result.whenComplete(timer.cancel); } } -class _AccountConsentDialog extends StatefulWidget { +class _AccountConsentDialog extends StatelessWidget { final String callerLabel; - final Duration timeout; - - const _AccountConsentDialog({ - required this.callerLabel, - required this.timeout, - }); - - @override - State<_AccountConsentDialog> createState() => _AccountConsentDialogState(); -} -class _AccountConsentDialogState extends State<_AccountConsentDialog> { - late final Timer _timer; - - @override - void initState() { - super.initState(); - _timer = Timer(widget.timeout, () { - if (mounted) Navigator.of(context).pop(); - }); - } - - @override - void dispose() { - _timer.cancel(); - super.dispose(); - } + const _AccountConsentDialog({required this.callerLabel}); @override Widget build(BuildContext context) { return ShadDialog( title: const Text('Decent account access'), description: Text( - '${widget.callerLabel} wants to use your linked Decent account.', + '$callerLabel wants to use your linked Decent account.', ), actions: [ ShadButton.outline( diff --git a/lib/src/app.dart b/lib/src/app.dart index c6f432620..ffc474861 100644 --- a/lib/src/app.dart +++ b/lib/src/app.dart @@ -495,6 +495,7 @@ class _MyAppState extends State { webViewLogService: widget.webViewLogService, deviceIp: widget.webUIService.deviceIp(), displayController: widget.displayController, + port: widget.webUIService.port, ); default: return OnboardingView( diff --git a/lib/src/services/account/account_consent_gate.dart b/lib/src/services/account/account_consent_gate.dart index ccfa83a98..7cd4350fc 100644 --- a/lib/src/services/account/account_consent_gate.dart +++ b/lib/src/services/account/account_consent_gate.dart @@ -14,26 +14,31 @@ class ActiveSkinConsent { final String path; const ActiveSkinConsent({this.id, required this.name, required this.path}); + + String get key { + final installedId = id?.trim(); + return installedId != null && installedId.isNotEmpty + ? 'skin:$installedId' + : 'skin:path:${_pathDigest(path)}'; + } } class AccountConsentGate { final AccountConsentStore _store; - final ActiveSkinConsent? Function() _activeSkin; final AccountConsentPrompt _prompt; final Set _trustedConsentKeys; final bool _trustAllConsent; final Logger _log; final Map> _pending = {}; + final Map _callerLabels = {}; AccountConsentGate({ required AccountConsentStore store, - required ActiveSkinConsent? Function() activeSkin, required AccountConsentPrompt prompt, Set trustedConsentKeys = const {}, bool trustAllConsent = false, Logger? log, }) : _store = store, - _activeSkin = activeSkin, _prompt = prompt, _trustedConsentKeys = Set.unmodifiable( trustedConsentKeys @@ -43,10 +48,14 @@ class AccountConsentGate { _trustAllConsent = trustAllConsent, _log = log ?? Logger('AccountConsentGate'); - Future requireConsent(String callerId) async { + void registerCallerLabel(String callerId, String label) { final id = callerId.trim(); - if (_trustAllConsent && id == 'skin') return true; + final value = label.trim(); + if (id.isNotEmpty && value.isNotEmpty) _callerLabels[id] = value; + } + Future requireConsent(String callerId) async { + final id = callerId.trim(); final subject = _subjectFor(id); if (subject == null) return false; if (_trustAllConsent || _trustedConsentKeys.contains(subject.key)) { @@ -111,15 +120,10 @@ class AccountConsentGate { } _ConsentSubject? _subjectFor(String callerId) { - if (callerId == 'skin') { - final skin = _activeSkin(); - if (skin == null || skin.path.trim().isEmpty) return null; - final id = skin.id?.trim(); - final key = id != null && id.isNotEmpty - ? 'skin:$id' - : 'skin:path:${_pathDigest(skin.path)}'; - final label = skin.name.trim().isEmpty ? 'Custom skin' : skin.name.trim(); - return _ConsentSubject(key, label); + if (callerId.startsWith('skin:')) { + final id = callerId.substring(5).trim(); + if (id.isEmpty) return null; + return _ConsentSubject(callerId, _callerLabels[callerId] ?? 'Skin "$id"'); } if (callerId.startsWith('plugin:')) { @@ -134,12 +138,12 @@ class AccountConsentGate { } return null; } - - String _pathDigest(String path) => sha256 - .convert(utf8.encode(p.normalize(p.absolute(path.trim())))) - .toString(); } +String _pathDigest(String path) => sha256 + .convert(utf8.encode(p.normalize(p.absolute(path.trim())))) + .toString(); + class _ConsentSubject { final String key; final String label; diff --git a/lib/src/services/account/proxy_token_service.dart b/lib/src/services/account/proxy_token_service.dart index ab68a4806..34d3c5915 100644 --- a/lib/src/services/account/proxy_token_service.dart +++ b/lib/src/services/account/proxy_token_service.dart @@ -14,7 +14,7 @@ class ProxyTokenService { static const String scopeAccountProxyWrite = 'account:proxy:write'; final Map _tokens = {}; - late final String _skinToken; + late String _skinToken; ProxyTokenService({String? skinToken}) { _skinToken = skinToken ?? generateToken(); @@ -26,6 +26,17 @@ class ProxyTokenService { String get skinToken => _skinToken; + String rotateSkinToken(ProxyCaller caller) { + _tokens.remove(_skinToken); + _skinToken = generateToken(); + _tokens[_skinToken] = caller; + return _skinToken; + } + + void revokeSkinToken() { + _tokens.remove(_skinToken); + } + void registerToken(String token, ProxyCaller caller) { _tokens[token] = caller; } diff --git a/lib/src/skin_feature/skin_view.dart b/lib/src/skin_feature/skin_view.dart index 056881e9c..b10c91a76 100644 --- a/lib/src/skin_feature/skin_view.dart +++ b/lib/src/skin_feature/skin_view.dart @@ -40,16 +40,17 @@ String skinExitInstructions(TargetPlatform platform) { return '$purpose $navigation'; } -SkinNavDecision classifySkinNavigation(Uri? url) { +SkinNavDecision classifySkinNavigation(Uri? url, {int skinPort = 3000}) { if (url == null) return SkinNavDecision.block; if (url.host == 'localhost' && url.path.startsWith('/__decent/')) { - return url.toString() == skinExitDashboardUrl + return url.toString() == skinExitDashboardUrlForPort(skinPort) ? SkinNavDecision.exitDashboard : SkinNavDecision.block; } if (url.scheme == 'http' && url.host == 'localhost' && - (url.port == 3000 || + (url.port == skinPort || + url.port == 3000 || (url.port == 8080 && url.path.startsWith('/api/v1/plugins/')))) { return SkinNavDecision.allow; } @@ -70,14 +71,15 @@ class SkinExitCoordinator { required Uri? target, required bool isForMainFrame, required Uri? topLevelUri, + int skinPort = 3000, }) { if (_inProgress || !isForMainFrame || - target?.toString() != skinExitDashboardUrl || + target?.toString() != skinExitDashboardUrlForPort(skinPort) || topLevelUri == null || topLevelUri.scheme != 'http' || topLevelUri.host != 'localhost' || - topLevelUri.port != 3000 || + topLevelUri.port != skinPort || topLevelUri.userInfo.isNotEmpty) { return false; } @@ -94,6 +96,7 @@ class SkinView extends StatefulWidget { required this.deviceIp, required this.displayController, this.webView, + required this.port, }); final SettingsController settingsController; @@ -102,6 +105,7 @@ class SkinView extends StatefulWidget { final DisplayController displayController; @visibleForTesting final Widget? webView; + final int port; static const routeName = '/skin'; @@ -131,7 +135,7 @@ class _SkinViewState extends State with WidgetsBindingObserver { bool _didShowExit = false; String get _skinUrl => - 'http://localhost:3000/?_=${DateTime.now().millisecondsSinceEpoch}'; + 'http://localhost:${widget.port}/?_=${DateTime.now().millisecondsSinceEpoch}'; @override void initState() { @@ -664,7 +668,8 @@ class _SkinViewState extends State with WidgetsBindingObserver { }, onReceivedError: (controller, request, error) { if (_skinExitCoordinator.inProgress && - request.url.toString() == skinExitDashboardUrl) { + request.url.toString() == + skinExitDashboardUrlForPort(widget.port)) { return; } _log.warning( @@ -682,12 +687,13 @@ class _SkinViewState extends State with WidgetsBindingObserver { }, shouldOverrideUrlLoading: (controller, navigationAction) async { final uri = navigationAction.request.url; - switch (classifySkinNavigation(uri)) { + switch (classifySkinNavigation(uri, skinPort: widget.port)) { case SkinNavDecision.exitDashboard: if (_skinExitCoordinator.tryStart( target: uri, isForMainFrame: navigationAction.isForMainFrame, topLevelUri: _mainFrameUri, + skinPort: widget.port, )) { _log.info('Skin requested dashboard'); if (mounted) _exitToDashboard(); diff --git a/lib/src/webui_support/webui_service.dart b/lib/src/webui_support/webui_service.dart index 51fdc88ab..02152176b 100644 --- a/lib/src/webui_support/webui_service.dart +++ b/lib/src/webui_support/webui_service.dart @@ -80,14 +80,17 @@ List injectSkinApiScriptTagBytes( ); } -String buildSkinApiJavaScript() { +String skinExitDashboardUrlForPort(int port) => + 'http://localhost:$port$skinExitDashboardPath'; + +String buildSkinApiJavaScript({int port = 3000}) { return 'var tokenMeta=document.querySelector(' '${jsonEncode('meta[name="$_skinProxyTokenMetaName"]')});' 'if(tokenMeta)window.__REA_PROXY_TOKEN__=tokenMeta.content;' 'window.decentApp=window.decentApp||{};' 'window.decentApp.exitToDashboard=function(){' 'if(window.__DECENT_HOST__)window.location.assign(' - '${jsonEncode(skinExitDashboardUrl)});' + '${jsonEncode(skinExitDashboardUrlForPort(port))});' '};'; } @@ -103,6 +106,8 @@ class WebUIService { final _log = Logger("WebUIService"); final Future> Function() _listLocalAddresses; HttpServer? _server; + HttpServer? _entryServer; + final Set _usedPorts = {}; int port = 3000; String _path = ""; String? _localIP; @@ -126,6 +131,8 @@ class WebUIService { SkinOverride skinOverride = const SkinOverride.registry(); String? skinProxyToken; + String? Function(String path)? skinProxyTokenProvider; + void Function()? skinProxyTokenRevoker; Future _isLocalHost(String host) async { if (host == 'localhost' || host == '127.0.0.1' || host == '::1') { @@ -155,6 +162,9 @@ class WebUIService { Future serveFolderAtPath(String path, {int port = 3000}) async { await _server?.close(force: true); + _server = null; + final tokenProvider = skinProxyTokenProvider; + if (tokenProvider != null) _revokeSkinProxyToken(); _localIP ??= await _resolveLocalIP(); final webUI = createStaticHandler( @@ -167,7 +177,9 @@ class WebUIService { FutureOr skinHandler(Request request) { if (request.url.path == skinApiScriptPath.substring(1)) { return Response.ok( - request.method == 'HEAD' ? null : buildSkinApiJavaScript(), + request.method == 'HEAD' + ? null + : buildSkinApiJavaScript(port: this.port), headers: { 'Content-Type': 'application/javascript; charset=utf-8', 'Cache-Control': 'no-store', @@ -212,7 +224,7 @@ class WebUIService { response.headers.containsKey('content-encoding')) { return response; } - final scriptUrl = await _skinApiUrl(request, port); + final scriptUrl = await _skinApiUrl(request, this.port); if (scriptUrl == null) return response; final encoding = response.encoding ?? utf8; final body = await response.read().expand((chunk) => chunk).toList(); @@ -243,15 +255,60 @@ class WebUIService { .addHandler(skinHandler); try { - _server = await shelf_io.serve(handler, '0.0.0.0', port); + if (tokenProvider != null) skinProxyToken = tokenProvider(path); + _server = await _serveFresh(handler); + this.port = _server!.port; + await _serveEntryPoint(port); _log.fine("serving $path"); _path = path; } catch (e, st) { + await _server?.close(force: true); + await _entryServer?.close(force: true); + _server = null; + _entryServer = null; + _revokeSkinProxyToken(); _log.severe("failed to start serving", e, st); rethrow; } } + void _revokeSkinProxyToken() { + if (skinProxyToken != null) skinProxyTokenRevoker?.call(); + skinProxyToken = null; + } + + Future _serveFresh(Handler handler) async { + while (true) { + final server = await shelf_io.serve(handler, '0.0.0.0', 0); + if (_usedPorts.add(server.port)) return server; + await server.close(force: true); + } + } + + Future _serveEntryPoint(int requestedPort) async { + if (_entryServer?.port == requestedPort) return; + await _entryServer?.close(force: true); + _entryServer = await shelf_io.serve( + (request) async { + final uri = request.requestedUri; + if (uri.scheme != 'http' || + uri.userInfo.isNotEmpty || + !await _isLocalHost(uri.host)) { + return Response.notFound('Not found'); + } + return Response( + HttpStatus.temporaryRedirect, + headers: { + HttpHeaders.locationHeader: uri.replace(port: port).toString(), + HttpHeaders.cacheControlHeader: 'no-store', + }, + ); + }, + '0.0.0.0', + requestedPort, + ); + } + String serverIP() { _log.fine("server ip: ${_server?.address.address}"); return Platform.isAndroid @@ -270,12 +327,15 @@ class WebUIService { bool get isServing => _server != null; Future stopServing() async { - if (_server != null) { + if (_server != null || _entryServer != null) { _log.info('Stopping WebUI server on port $port'); await _server?.close(force: true); + await _entryServer?.close(force: true); _server = null; + _entryServer = null; _path = ""; _log.info('WebUI server stopped'); } + _revokeSkinProxyToken(); } } diff --git a/test/account/account_consent_prompter_test.dart b/test/account/account_consent_prompter_test.dart index 14dca7b2c..3fb0abe36 100644 --- a/test/account/account_consent_prompter_test.dart +++ b/test/account/account_consent_prompter_test.dart @@ -71,6 +71,25 @@ void main() { expect(find.text('Decent account access'), findsNothing); }); + testWidgets('concurrent timeouts remove their own dialog routes', ( + tester, + ) async { + await pumpApp(tester); + final prompter = AccountConsentPrompter( + navigatorKey: navigatorKey, + timeout: const Duration(milliseconds: 20), + ); + + final first = prompter.prompt('Aileen'); + final second = prompter.prompt('Plugin "dye2"'); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 20)); + await tester.pumpAndSettle(); + + expect(await Future.wait([first, second]), [isNull, isNull]); + expect(find.text('Decent account access'), findsNothing); + }); + test('no attached navigator denies without showing UI', () async { final prompter = AccountConsentPrompter(navigatorKey: navigatorKey); diff --git a/test/services/account/account_consent_gate_test.dart b/test/services/account/account_consent_gate_test.dart index 0ca1c4124..4ccd9ccf1 100644 --- a/test/services/account/account_consent_gate_test.dart +++ b/test/services/account/account_consent_gate_test.dart @@ -26,17 +26,11 @@ class _CredentialStore implements CredentialStore { void main() { late _CredentialStore credentials; late AccountConsentStore store; - late ActiveSkinConsent? activeSkin; late List prompts; setUp(() { credentials = _CredentialStore(); store = AccountConsentStore(credentialStore: credentials); - activeSkin = const ActiveSkinConsent( - id: 'aileen', - name: 'Aileen', - path: '/skins/aileen', - ); prompts = []; }); @@ -46,7 +40,6 @@ void main() { bool trustAllConsent = false, }) => AccountConsentGate( store: store, - activeSkin: () => activeSkin, prompt: prompt ?? (label) async { @@ -59,9 +52,10 @@ void main() { test('prompts once and remembers an installed skin allow', () async { final consent = gate(); + consent.registerCallerLabel('skin:aileen', 'Aileen'); - expect(await consent.requireConsent('skin'), isTrue); - expect(await consent.requireConsent('skin'), isTrue); + expect(await consent.requireConsent('skin:aileen'), isTrue); + expect(await consent.requireConsent('skin:aileen'), isTrue); expect(prompts, ['Aileen']); expect(await store.read('skin:aileen'), AccountConsentDecision.allowed); }); @@ -82,25 +76,25 @@ void main() { test('a different skin id cannot inherit consent', () async { final consent = gate(); + consent.registerCallerLabel('skin:aileen', 'Aileen'); + consent.registerCallerLabel('skin:streamline', 'Streamline'); - expect(await consent.requireConsent('skin'), isTrue); - activeSkin = const ActiveSkinConsent( - id: 'streamline', - name: 'Streamline', - path: '/skins/streamline', - ); - expect(await consent.requireConsent('skin'), isTrue); + expect(await consent.requireConsent('skin:aileen'), isTrue); + expect(await consent.requireConsent('skin:streamline'), isTrue); expect(prompts, ['Aileen', 'Streamline']); }); test('a custom skin path is hashed instead of persisted', () async { - activeSkin = const ActiveSkinConsent( + final skin = const ActiveSkinConsent( name: 'Custom skin', path: r'C:\Users\rea\private-skin', ); - expect(await gate().requireConsent('skin'), isTrue); + final consent = gate(); + consent.registerCallerLabel(skin.key, skin.name); + + expect(await consent.requireConsent(skin.key), isTrue); final persisted = credentials.values['account_proxy_consent']!; expect(persisted, contains('skin:path:')); @@ -141,16 +135,15 @@ void main() { }); test('headless skin request denies without a wildcard decision', () async { - activeSkin = null; - expect(await gate().requireConsent('skin'), isFalse); expect(prompts, isEmpty); }); test('trust-all permits a headless skin caller', () async { - activeSkin = null; - - expect(await gate(trustAllConsent: true).requireConsent('skin'), isTrue); + expect( + await gate(trustAllConsent: true).requireConsent('skin:aileen'), + isTrue, + ); expect(prompts, isEmpty); }); diff --git a/test/services/account/proxy_token_service_test.dart b/test/services/account/proxy_token_service_test.dart index a71669a78..4e7a1698c 100644 --- a/test/services/account/proxy_token_service_test.dart +++ b/test/services/account/proxy_token_service_test.dart @@ -20,6 +20,36 @@ void main() { expect(ProxyTokenService().skinToken, isNot(ProxyTokenService().skinToken)); }); + test('rotating the skin token binds identity and revokes the old token', () { + final service = ProxyTokenService(); + final oldToken = service.skinToken; + + final newToken = service.rotateSkinToken( + const ProxyCaller( + id: 'skin:aileen', + scopes: {ProxyTokenService.scopeAccountProxy}, + ), + ); + + expect(newToken, isNot(oldToken)); + expect(service.validate(oldToken), isNull); + expect(service.validate(newToken)?.id, 'skin:aileen'); + }); + + test('revoking the skin token ends the served skin session', () { + final service = ProxyTokenService(); + final token = service.rotateSkinToken( + const ProxyCaller( + id: 'skin:aileen', + scopes: {ProxyTokenService.scopeAccountProxy}, + ), + ); + + service.revokeSkinToken(); + + expect(service.validate(token), isNull); + }); + test('unknown tokens do not validate', () { final service = ProxyTokenService(); expect(service.validate('nope'), isNull); diff --git a/test/unit/skin_feature/skin_navigation_test.dart b/test/unit/skin_feature/skin_navigation_test.dart index 172cd8bba..f60731815 100644 --- a/test/unit/skin_feature/skin_navigation_test.dart +++ b/test/unit/skin_feature/skin_navigation_test.dart @@ -75,6 +75,7 @@ void main() { webViewLogService: webViewLogService, deviceIp: '127.0.0.1', displayController: displayController, + port: 43210, webView: const SizedBox.expand(key: Key('webview')), ), }, @@ -133,6 +134,7 @@ void main() { webViewLogService: webViewLogService, deviceIp: '127.0.0.1', displayController: displayController, + port: 43210, webView: const SizedBox.expand(key: Key('webview')), ), }, @@ -154,13 +156,26 @@ void main() { }); group('classifySkinNavigation', () { - test('allows localhost:3000 and its sub-paths', () { + test('allows the active skin origin and its sub-paths', () { expect( - classifySkinNavigation(Uri.parse('http://localhost:3000/')), + classifySkinNavigation( + Uri.parse('http://localhost:43210/'), + skinPort: 43210, + ), SkinNavDecision.allow, ); expect( - classifySkinNavigation(Uri.parse('http://localhost:3000/foo?x=1')), + classifySkinNavigation( + Uri.parse('http://localhost:43210/foo?x=1'), + skinPort: 43210, + ), + SkinNavDecision.allow, + ); + expect( + classifySkinNavigation( + Uri.parse('http://localhost:3000/'), + skinPort: 43210, + ), SkinNavDecision.allow, ); }); @@ -175,25 +190,27 @@ void main() { }); test('exits to the dashboard for the exact skin exit URL', () { + final url = skinExitDashboardUrlForPort(43210); expect( - classifySkinNavigation(Uri.parse(skinExitDashboardUrl)), + classifySkinNavigation(Uri.parse(url), skinPort: 43210), SkinNavDecision.exitDashboard, ); }); test('blocks extended and malformed skin exit URLs', () { + final url = skinExitDashboardUrlForPort(43210); for (final url in [ - '$skinExitDashboardUrl/path', - '$skinExitDashboardUrl?unexpected=true', - '$skinExitDashboardUrl#fragment', - 'http://user@localhost:3000$skinExitDashboardPath', - 'http://localhost:3001$skinExitDashboardPath', + '$url/path', + '$url?unexpected=true', + '$url#fragment', + 'http://user@localhost:43210$skinExitDashboardPath', + 'http://localhost:43211$skinExitDashboardPath', 'http://localhost$skinExitDashboardPath', - 'https://localhost:3000$skinExitDashboardPath', - 'http://example.com:3000$skinExitDashboardPath', + 'https://localhost:43210$skinExitDashboardPath', + 'http://example.com:43210$skinExitDashboardPath', ]) { expect( - classifySkinNavigation(Uri.parse(url)), + classifySkinNavigation(Uri.parse(url), skinPort: 43210), isNot(SkinNavDecision.exitDashboard), reason: url, ); @@ -239,8 +256,8 @@ void main() { }); group('SkinExitCoordinator', () { - final target = Uri.parse(skinExitDashboardUrl); - final trustedPage = Uri.parse('http://localhost:3000/?_=123'); + final target = Uri.parse(skinExitDashboardUrlForPort(43210)); + final trustedPage = Uri.parse('http://localhost:43210/?_=123'); test('accepts one trusted main-frame request', () { final coordinator = SkinExitCoordinator(); @@ -250,6 +267,7 @@ void main() { target: target, isForMainFrame: true, topLevelUri: trustedPage, + skinPort: 43210, ), isTrue, ); @@ -258,6 +276,7 @@ void main() { target: target, isForMainFrame: true, topLevelUri: trustedPage, + skinPort: 43210, ), isFalse, ); @@ -270,6 +289,7 @@ void main() { target: target, isForMainFrame: false, topLevelUri: trustedPage, + skinPort: 43210, ), isFalse, ); @@ -277,7 +297,8 @@ void main() { SkinExitCoordinator().tryStart( target: target, isForMainFrame: true, - topLevelUri: Uri.parse('http://example.com:3000/'), + topLevelUri: Uri.parse('http://example.com:43210/'), + skinPort: 43210, ), isFalse, ); diff --git a/test/unit/skin_feature/skin_view_brightness_test.dart b/test/unit/skin_feature/skin_view_brightness_test.dart index aea05d611..0c77fd9bf 100644 --- a/test/unit/skin_feature/skin_view_brightness_test.dart +++ b/test/unit/skin_feature/skin_view_brightness_test.dart @@ -48,6 +48,7 @@ void main() { webViewLogService: logs, deviceIp: '127.0.0.1', displayController: displayController, + port: 43210, ), ), ); diff --git a/test/webui_support/webui_token_injection_test.dart b/test/webui_support/webui_token_injection_test.dart index 46e46b490..458475da1 100644 --- a/test/webui_support/webui_token_injection_test.dart +++ b/test/webui_support/webui_token_injection_test.dart @@ -117,8 +117,10 @@ const bodyExample = ""; Future getBodyForHost(String host) async { final client = HttpClient(); addTearDown(client.close); - final request = await client.getUrl(Uri.parse('http://localhost:3001/')); - request.headers.set(HttpHeaders.hostHeader, '$host:3001'); + final request = await client.getUrl( + Uri.parse('http://localhost:${service.port}/'), + ); + request.headers.set(HttpHeaders.hostHeader, '$host:${service.port}'); final response = await request.close(); return response.transform(utf8.decoder).join(); } @@ -137,35 +139,44 @@ const bodyExample = ""; final client = HttpClient(); addTearDown(client.close); - final request = await client.getUrl(Uri.parse('http://localhost:3000/')); + final request = await client.getUrl( + Uri.parse('http://localhost:${service.port}/'), + ); final response = await request.close(); final body = await response.transform(utf8.decoder).join(); - expect(body, contains(scriptTag)); + expect( + body, + contains( + '', + ), + ); expect(body, contains("script-src 'self'")); expect(body, contains('content="$token"')); expect(response.headers.value(HttpHeaders.acceptRangesHeader), isNull); expect(response.headers.value(HttpHeaders.lastModifiedHeader), isNull); final lanRequest = await client.getUrl( - Uri.parse('http://localhost:3000/'), + Uri.parse('http://localhost:${service.port}/'), ); - lanRequest.headers.set(HttpHeaders.hostHeader, '$lanIp:3000'); + lanRequest.headers.set(HttpHeaders.hostHeader, '$lanIp:${service.port}'); final lanResponse = await lanRequest.close(); final lanBody = await lanResponse.transform(utf8.decoder).join(); expect( lanBody, contains( - '', + '', ), ); final untrustedRequest = await client.getUrl( - Uri.parse('http://localhost:3000/'), + Uri.parse('http://localhost:${service.port}/'), ); untrustedRequest.headers.set( HttpHeaders.hostHeader, - 'example.invalid:3000', + 'example.invalid:${service.port}', ); final untrustedResponse = await untrustedRequest.close(); final untrustedBody = await untrustedResponse @@ -175,7 +186,7 @@ const bodyExample = ""; expect(untrustedBody, isNot(contains(skinApiScriptPath))); final scriptRequest = await client.getUrl( - Uri.parse('http://localhost:3000$skinApiScriptPath'), + Uri.parse('http://localhost:${service.port}$skinApiScriptPath'), ); final scriptResponse = await scriptRequest.close(); final script = await scriptResponse.transform(utf8.decoder).join(); @@ -193,6 +204,62 @@ const bodyExample = ""; expect(script, isNot(contains(token))); }); + test('switching skins rotates both browser origin and token', () async { + final secondDir = await Directory.systemTemp.createTemp( + 'webui_second_skin', + ); + addTearDown(() => secondDir.delete(recursive: true)); + await File( + '${secondDir.path}/index.html', + ).writeAsString('second'); + var generation = 0; + service.skinProxyTokenProvider = (_) => 'token-${++generation}'; + + await service.serveFolderAtPath(tempDir.path); + final firstPort = service.port; + final firstToken = service.skinProxyToken; + await service.serveFolderAtPath(secondDir.path); + + expect(service.port, isNot(firstPort)); + expect(service.skinProxyToken, isNot(firstToken)); + + final client = HttpClient(); + addTearDown(client.close); + await expectLater( + client.getUrl(Uri.parse('http://localhost:$firstPort/')), + throwsA(isA()), + ); + final entryRequest = await client.getUrl( + Uri.parse('http://localhost:3000/'), + ); + entryRequest.followRedirects = false; + final entryResponse = await entryRequest.close(); + expect(entryResponse.statusCode, HttpStatus.temporaryRedirect); + expect( + entryResponse.headers.value(HttpHeaders.locationHeader), + 'http://localhost:${service.port}/', + ); + final request = await client.getUrl( + Uri.parse('http://localhost:${service.port}/'), + ); + final response = await request.close(); + final body = await response.transform(utf8.decoder).join(); + expect(body, contains('content="token-2"')); + expect(body, isNot(contains('token-1'))); + }); + + test('stopping the server revokes the served skin token', () async { + var revocations = 0; + service.skinProxyTokenProvider = (_) => token; + service.skinProxyTokenRevoker = () => revocations++; + await service.serveFolderAtPath(tempDir.path); + + await service.stopServing(); + + expect(revocations, 1); + expect(service.skinProxyToken, isNull); + }); + test('accepts another local interface address', () async { const wifiIp = '192.168.50.20'; const ethernetIp = '10.0.0.7'; @@ -207,7 +274,7 @@ const bodyExample = ""; expect( body, contains( - '', ), ); From 6f31c4651afda708619c900105bce9e815a821fc Mon Sep 17 00:00:00 2001 From: ODevStudio Date: Sat, 22 Aug 2026 16:13:47 +0200 Subject: [PATCH 3/4] docs(consent): follow skin server redirect in smoke test --- .agents/skills/decent-app/scenarios/account-proxy-consent.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.agents/skills/decent-app/scenarios/account-proxy-consent.md b/.agents/skills/decent-app/scenarios/account-proxy-consent.md index 72f4478a7..681ea6753 100644 --- a/.agents/skills/decent-app/scenarios/account-proxy-consent.md +++ b/.agents/skills/decent-app/scenarios/account-proxy-consent.md @@ -25,7 +25,7 @@ In another terminal, wait for the servers and obtain the injected skin token: ```bash until curl -sf http://localhost:8080/api/v1/info >/dev/null; do sleep 1; done P=/api/v1/account/proxy/support/api/sn -TOK=$(curl -s http://localhost:3000/ \ +TOK=$(curl -sL http://localhost:3000/ \ | sed -n 's/.*name="reaprime-proxy-token" content="\([^"]*\)".*/\1/p' \ | head -1) test -n "$TOK" @@ -59,7 +59,7 @@ Fetch the new process token and repeat the request: ```bash until curl -sf http://localhost:8080/api/v1/info >/dev/null; do sleep 1; done -TOK=$(curl -s http://localhost:3000/ \ +TOK=$(curl -sL http://localhost:3000/ \ | sed -n 's/.*name="reaprime-proxy-token" content="\([^"]*\)".*/\1/p' \ | head -1) status=$(curl -sS -o /tmp/decaid-consent-body -w '%{http_code}' \ From e778752b3cad2a479eb1e6ce624105ce656bee98 Mon Sep 17 00:00:00 2001 From: ODevStudio Date: Mon, 24 Aug 2026 11:53:31 +0200 Subject: [PATCH 4/4] fix(account): bind consent to token identity --- doc/AI_BUILD_NOTES.md | 2 +- .../account-proxy-tokens-297.md | 5 +- lib/main.dart | 1 + .../account_tokens_controller.dart | 26 +++++---- .../account/account_consent_gate.dart | 5 +- .../services/account/proxy_token_store.dart | 11 +++- .../account_tokens_controller_test.dart | 53 +++++++++++++++++-- .../account/proxy_token_store_test.dart | 18 +++++++ 8 files changed, 103 insertions(+), 18 deletions(-) diff --git a/doc/AI_BUILD_NOTES.md b/doc/AI_BUILD_NOTES.md index 4aeb4af16..1201ae0a9 100644 --- a/doc/AI_BUILD_NOTES.md +++ b/doc/AI_BUILD_NOTES.md @@ -197,7 +197,7 @@ The app supports several command-line flags for headless/calibration-station use --trust-all-consent # Trust every account-proxy caller for this process ``` -All flags are optional. Combine as needed. `--no-account` is specifically for headless Linux stations where `libsecret` blocks on XDG secrets portal. Consent keys use `skin:`, `plugin:`, or `api:`; both trust flags are session-only and are never persisted. With `flutter run`, pass each app flag separately as `--dart-entrypoint-args=`; `--dart-define` does not populate `main()` arguments. +All flags are optional. Combine as needed. `--no-account` is specifically for headless Linux stations where `libsecret` blocks on XDG secrets portal. Consent keys use `skin:`, `plugin:`, or `api:`; API token labels are presentation-only. Both trust flags are session-only and are never persisted. With `flutter run`, pass each app flag separately as `--dart-entrypoint-args=`; `--dart-define` does not populate `main()` arguments. ## Dev-Loop Skill diff --git a/doc/plans/archive/account-proxy-tokens/account-proxy-tokens-297.md b/doc/plans/archive/account-proxy-tokens/account-proxy-tokens-297.md index 037ddfc55..000d24afe 100644 --- a/doc/plans/archive/account-proxy-tokens/account-proxy-tokens-297.md +++ b/doc/plans/archive/account-proxy-tokens/account-proxy-tokens-297.md @@ -17,7 +17,7 @@ Per the session decision, we also lay the **write-scope** capability so #355 ## Scope (from issue #297) -- Persist API-client tokens (`{token, label, scopes, createdAt}`) across restarts. +- Persist API-client tokens (`{id, token, label, scopes, createdAt}`) across restarts. - Load persisted tokens into `ProxyTokenService` at startup, alongside the skin token. - Settings UI: create a named token, show it **once** to copy, list existing, revoke. @@ -53,7 +53,8 @@ class SecureProxyTokenStore implements ProxyTokenStore // FlutterSecureStorage, class InMemoryProxyTokenStore implements ProxyTokenStore // tests + headless fallback ``` -`PersistedProxyToken = {token, label, scopes:Set, createdAt}`. +`PersistedProxyToken = {id, token, label, scopes:Set, createdAt}`. The +non-secret UUID is the immutable caller identity; the label is presentation-only. Stored as a JSON list under a single secure-storage key. We persist the **raw token** (same trust level as the account password already in secure storage) because `validate()` is an exact-match lookup — storing only a hash would require changing diff --git a/lib/main.dart b/lib/main.dart index c059513a2..f31084498 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -449,6 +449,7 @@ void main(List args) async { accountTokensController = AccountTokensController( tokenService: proxyTokenService, store: ProxyTokenStore(credentialStore: credentialStore), + callerLabelRegistrar: gate.registerCallerLabel, ); await accountTokensController.initialize(); } diff --git a/lib/src/controllers/account_tokens_controller.dart b/lib/src/controllers/account_tokens_controller.dart index 746fa4303..b3c02a4dc 100644 --- a/lib/src/controllers/account_tokens_controller.dart +++ b/lib/src/controllers/account_tokens_controller.dart @@ -1,10 +1,12 @@ import 'package:logging/logging.dart'; import 'package:reaprime/src/services/account/proxy_token_service.dart'; import 'package:reaprime/src/services/account/proxy_token_store.dart'; +import 'package:uuid/uuid.dart'; class AccountTokensController { final ProxyTokenService _tokenService; final ProxyTokenStore _store; + final void Function(String callerId, String label)? _callerLabelRegistrar; final Logger _log = Logger('AccountTokensController'); final List _tokens = []; @@ -12,8 +14,10 @@ class AccountTokensController { AccountTokensController({ required ProxyTokenService tokenService, required ProxyTokenStore store, + void Function(String callerId, String label)? callerLabelRegistrar, }) : _tokenService = tokenService, - _store = store; + _store = store, + _callerLabelRegistrar = callerLabelRegistrar; List get tokens => List.unmodifiable(_tokens); @@ -24,10 +28,7 @@ class AccountTokensController { ..clear() ..addAll(persisted); for (final t in persisted) { - _tokenService.registerToken( - t.token, - ProxyCaller(id: 'api:${t.label}', scopes: t.scopes), - ); + _register(t); } } catch (e, st) { _log.warning('Failed to load persisted proxy tokens', e, st); @@ -41,16 +42,14 @@ class AccountTokensController { if (write) ProxyTokenService.scopeAccountProxyWrite, }; final record = PersistedProxyToken( + id: const Uuid().v4(), token: token, label: label, scopes: scopes, createdAt: DateTime.now(), ); - _tokenService.registerToken( - token, - ProxyCaller(id: 'api:$label', scopes: scopes), - ); + _register(record); _tokens.add(record); await _store.save(_tokens); return token; @@ -61,4 +60,13 @@ class AccountTokensController { _tokens.removeWhere((t) => t.token == token); await _store.save(_tokens); } + + void _register(PersistedProxyToken token) { + final callerId = 'api:${token.id}'; + _tokenService.registerToken( + token.token, + ProxyCaller(id: callerId, scopes: token.scopes), + ); + _callerLabelRegistrar?.call(callerId, token.label); + } } diff --git a/lib/src/services/account/account_consent_gate.dart b/lib/src/services/account/account_consent_gate.dart index 7cd4350fc..a69d92990 100644 --- a/lib/src/services/account/account_consent_gate.dart +++ b/lib/src/services/account/account_consent_gate.dart @@ -134,7 +134,10 @@ class AccountConsentGate { if (callerId.startsWith('api:')) { final id = callerId.substring(4); if (id.trim().isEmpty) return null; - return _ConsentSubject(callerId, 'API client "$id"'); + return _ConsentSubject( + callerId, + _callerLabels[callerId] ?? 'API client "$id"', + ); } return null; } diff --git a/lib/src/services/account/proxy_token_store.dart b/lib/src/services/account/proxy_token_store.dart index a18adb939..6c43bae8a 100644 --- a/lib/src/services/account/proxy_token_store.dart +++ b/lib/src/services/account/proxy_token_store.dart @@ -2,14 +2,17 @@ import 'dart:convert'; import 'package:reaprime/src/services/account/decent_account_service.dart' show CredentialStore; +import 'package:uuid/uuid.dart'; class PersistedProxyToken { + final String id; final String token; final String label; final Set scopes; final DateTime createdAt; const PersistedProxyToken({ + required this.id, required this.token, required this.label, required this.scopes, @@ -17,6 +20,7 @@ class PersistedProxyToken { }); Map toJson() => { + 'id': id, 'token': token, 'label': label, 'scopes': scopes.toList(), @@ -25,6 +29,7 @@ class PersistedProxyToken { factory PersistedProxyToken.fromJson(Map json) => PersistedProxyToken( + id: json['id'] as String? ?? const Uuid().v4(), token: json['token'] as String, label: json['label'] as String, scopes: (json['scopes'] as List).map((e) => e as String).toSet(), @@ -46,9 +51,13 @@ class ProxyTokenStore { final raw = await _credentialStore.read(key: _storageKey); if (raw == null || raw.isEmpty) return []; final decoded = jsonDecode(raw) as List; - return decoded + final tokens = decoded .map((e) => PersistedProxyToken.fromJson(e as Map)) .toList(); + if (decoded.any((e) => (e as Map)['id'] == null)) { + await save(tokens); + } + return tokens; } Future save(List tokens) async { diff --git a/test/services/account/account_tokens_controller_test.dart b/test/services/account/account_tokens_controller_test.dart index 196adb74b..24b49fc46 100644 --- a/test/services/account/account_tokens_controller_test.dart +++ b/test/services/account/account_tokens_controller_test.dart @@ -1,4 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:reaprime/src/services/account/account_consent_gate.dart'; +import 'package:reaprime/src/services/account/account_consent_store.dart'; import 'package:reaprime/src/services/account/decent_account_service.dart' show CredentialStore; import 'package:reaprime/src/controllers/account_tokens_controller.dart'; @@ -20,11 +22,26 @@ void main() { late ProxyTokenService service; late ProxyTokenStore store; late AccountTokensController controller; + late AccountConsentGate consent; + late List prompts; setUp(() { service = ProxyTokenService(); - store = ProxyTokenStore(credentialStore: _FakeCredentialStore()); - controller = AccountTokensController(tokenService: service, store: store); + final credentials = _FakeCredentialStore(); + store = ProxyTokenStore(credentialStore: credentials); + prompts = []; + consent = AccountConsentGate( + store: AccountConsentStore(credentialStore: credentials), + prompt: (label) async { + prompts.add(label); + return AccountConsentDecision.allowed; + }, + ); + controller = AccountTokensController( + tokenService: service, + store: store, + callerLabelRegistrar: consent.registerCallerLabel, + ); }); test('create mints a token that validates with a read scope', () async { @@ -33,7 +50,8 @@ void main() { expect(token, isNotEmpty); final caller = service.validate(token); expect(caller, isNotNull); - expect(caller!.id, 'api:laptop'); + expect(caller!.id, startsWith('api:')); + expect(caller.id, isNot('api:laptop')); expect(caller.scopes, contains(ProxyTokenService.scopeAccountProxy)); expect( caller.scopes, @@ -73,9 +91,35 @@ void main() { expect(controller.tokens, isEmpty); }); + test('duplicate labels require independent consent', () async { + final first = await controller.create(label: 'station'); + final second = await controller.create(label: 'station'); + final firstId = service.validate(first)!.id; + final secondId = service.validate(second)!.id; + + expect(firstId, isNot(secondId)); + expect(await consent.requireConsent(firstId), isTrue); + expect(await consent.requireConsent(secondId), isTrue); + expect(prompts, ['station', 'station']); + }); + + test('recreated label requires new consent after revoke', () async { + final first = await controller.create(label: 'station'); + final firstId = service.validate(first)!.id; + expect(await consent.requireConsent(firstId), isTrue); + await controller.revoke(first); + + final replacement = await controller.create(label: 'station'); + final replacementId = service.validate(replacement)!.id; + expect(replacementId, isNot(firstId)); + expect(await consent.requireConsent(replacementId), isTrue); + expect(prompts, ['station', 'station']); + }); + test('initialize loads persisted tokens into the service', () async { await store.save([ PersistedProxyToken( + id: 'persisted-id', token: 'persisted-tok', label: 'desktop', scopes: {ProxyTokenService.scopeAccountProxy}, @@ -87,10 +131,11 @@ void main() { final freshController = AccountTokensController( tokenService: freshService, store: store, + callerLabelRegistrar: consent.registerCallerLabel, ); await freshController.initialize(); - expect(freshService.validate('persisted-tok')!.id, 'api:desktop'); + expect(freshService.validate('persisted-tok')!.id, 'api:persisted-id'); expect(freshController.tokens.map((t) => t.label), ['desktop']); }); } diff --git a/test/services/account/proxy_token_store_test.dart b/test/services/account/proxy_token_store_test.dart index 0eea9a951..4d75d5835 100644 --- a/test/services/account/proxy_token_store_test.dart +++ b/test/services/account/proxy_token_store_test.dart @@ -35,12 +35,14 @@ void main() { final created = DateTime.utc(2026, 6, 18, 12); await store.save([ PersistedProxyToken( + id: 'laptop-id', token: 'tok-abc', label: 'laptop', scopes: {ProxyTokenService.scopeAccountProxy}, createdAt: created, ), PersistedProxyToken( + id: 'ci-id', token: 'tok-def', label: 'ci', scopes: { @@ -54,6 +56,7 @@ void main() { final loaded = await store.load(); expect(loaded, hasLength(2)); expect(loaded[0].token, 'tok-abc'); + expect(loaded[0].id, 'laptop-id'); expect(loaded[0].label, 'laptop'); expect(loaded[0].scopes, {ProxyTokenService.scopeAccountProxy}); expect(loaded[0].createdAt, created); @@ -66,6 +69,7 @@ void main() { test('save overwrites the previous set', () async { await store.save([ PersistedProxyToken( + id: 'old-id', token: 'old', label: 'old', scopes: {ProxyTokenService.scopeAccountProxy}, @@ -75,4 +79,18 @@ void main() { await store.save([]); expect(await store.load(), isEmpty); }); + + test('load persists an id for legacy token records', () async { + await creds.write( + key: 'account_proxy_tokens', + value: + '[{"token":"legacy","label":"station","scopes":["account:proxy"],"createdAt":"2026-01-01T00:00:00.000Z"}]', + ); + + final firstId = (await store.load()).single.id; + final secondId = (await store.load()).single.id; + + expect(firstId, isNotEmpty); + expect(secondId, firstId); + }); }