Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ Versions follow the `version+build` scheme from `pubspec.yaml`, bumped via
- **Library consumers:** `ThreadStateWarning.sourcesSkipped`,
`RagSnapshot.carriesUnreadableCitations` and
`RagSnapshot.withUnreadableCitationsDropped`.
- **Library consumers:** `CallbackParams`, the type of `standardFlavor`'s
`callbackParams`, is exported.

### Changed

Expand All @@ -30,6 +32,13 @@ Versions follow the `version+build` scheme from `pubspec.yaml`, bumped via
- The account name and email come from the sign-in tokens instead of a
`/api/user_info` request, so they show as soon as the app opens. The lobby
and the room rail show the email once when it is also the name or username.
- After a web sign-in, the tokens leave the address bar before the app starts
loading rather than once it has booted. Forks with their own
`web/index.html` need the script in `docs/authoring-a-flavor.md`; without it
sign-in still works and the app logs an error.
- An auto-connect address (`?url=`, from the app's own sign-in prompts or an
iOS deep link) connects only to a server already in the app's list, at the
address it was added with; any other address is ignored.
- **Library consumers:** `AuthProviderConfig.scope` is `String?`.

### Removed
Expand All @@ -39,6 +48,9 @@ Versions follow the `version+build` scheme from `pubspec.yaml`, bumped via
they now pass through unprocessed. Reasoning an older backend stored as
`THINKING_*` events no longer shows when such a thread is reopened, and a
`MESSAGES_SNAPSHOT` no longer logs a warning.
- Web sign-in against backends that return the tokens in the query string
(before `v0.82.2` / `v0.83.2`); those put the tokens in the logs of the
server hosting the app.
- **Library consumers (breaking):** `StepProgress` removed from
`ExecutionEvent`; `bridgeBaseEvent` maps `STEP_STARTED` and every
`THINKING_*` event to `null`, and `processEvent` leaves the conversation and
Expand Down Expand Up @@ -89,6 +101,8 @@ Versions follow the `version+build` scheme from `pubspec.yaml`, bumped via
closed no longer throws.
- A failed history refresh over loaded messages is recorded in the
diagnostics log, by thread and type of failure.
- A web sign-in link whose query can't be decoded shows an error instead of
stopping the app from starting.

## [0.107.0+93] - 2026-09-29

Expand Down
51 changes: 47 additions & 4 deletions docs/authoring-a-flavor.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ extension and runs the contrast check.
import 'package:flutter/material.dart';
import 'package:soliplex_frontend/soliplex_frontend.dart';

Future<Flavor> myFlavor() {
Future<Flavor> myFlavor({required CallbackParams callbackParams}) {
final light = buildSoliplexThemeData(
colors: lightSoliplexColors.copyWith(primary: const Color(0xFF0A7AFF)),
brightness: Brightness.light);
Expand All @@ -52,6 +52,7 @@ Future<Flavor> myFlavor() {
),
defaultBackendUrl: 'https://api.mybrand.com',
theme: FlavorTheme.themeData(light: light, dark: dark),
callbackParams: callbackParams,
// Custom modules receive the composition kit, so they can share the
// standard flavor's session state:
// extraModules: (kit) => [MyCustomModule(kit.serverManager)],
Expand All @@ -61,7 +62,9 @@ Future<Flavor> myFlavor() {
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
installLogSinks();
final flavor = await myFlavor();
final callbackParams = CallbackParamsCapture.captureNow();
clearCallbackUrl();
final flavor = await myFlavor(callbackParams: callbackParams);
// Pass the builder, not the built config: `Flavor.build()` throws on an
// invalid configuration, and a throw out here lands before any view exists —
// which on iOS, macOS and Android is not a crash but a launch that never
Expand Down Expand Up @@ -125,6 +128,43 @@ exported for exactly this, and it is what the shell runs, so your tests cannot
drift from production by reproducing the composition slightly differently.
Prefer `standardFlavor` unless you genuinely need a different module graph.

## Web entry page

A fork with its own `web/index.html` must carry the sign-in callback script,
right after `<title>`, with no stylesheet `<link>` or `<script src>` above it,
since either would hold it back until that file downloads:

```html
<script>
(function () {
var route = '#/auth/callback';
var hash = window.location.hash;
if (hash.indexOf(route + '?') !== 0) return;
window.soliplexCallbackQuery = hash.substring(route.length + 1);
history.replaceState(
null, '',
window.location.origin + window.location.pathname +
window.location.search + route);
})();
</script>
```

After a web sign-in the backend sends the browser to
`#/auth/callback?token=…`. The script takes the tokens out of the address bar
before the app starts downloading, instead of leaving them there until Flutter
boots. Without it, sign-in still works, but the tokens stay visible for that
time and the app logs an error that names `web/index.html` and this document.

The browser still records the callback URL, tokens included, in its history.
In Chrome, placing the script after `<title>` makes the history list show the
page title rather than the tokens; only a backend change keeps them out of the
URL.

`main()` must call `CallbackParamsCapture.captureNow()`, then
`clearCallbackUrl()`, and pass the result to `standardFlavor` as
`callbackParams`, as the example above does. Call them after
`installLogSinks()`: without a sink, the missing-script error is discarded.

## Rules

- Build the theme with `buildSoliplexThemeData` (never a bare `ThemeData`) — the
Expand Down Expand Up @@ -233,8 +273,11 @@ Prefer `standardFlavor` unless you genuinely need a different module graph.
- Declaring a path public leaves your `initialRoute` untouched, so a cold launch
lands where it did unless you also set `signedOutLandingPath`. On web a URL to
a declared path opens it directly, because go_router prefers a non-`/`
platform route over `initialLocation`; native deep links do not, since no
platform in this repo enables Flutter deep linking.
platform route over `initialLocation`. On iOS, Flutter deep linking is on unless
`Info.plist` sets `FlutterDeepLinkingEnabled` to false, and the app registers
the `ai.soliplex.client` URL scheme, so `ai.soliplex.client:///<path>` reaches
a declared path after the first frame, once the app has built at its initial
route.
- A module needs no `go_router` dependency of its own, in `dependencies` or in
`dev_dependencies`. The barrel re-exports the routing types module authoring
and module *testing* use — `GoRoute`, `GoRouter`, `GoRouterHelper`,
Expand Down
36 changes: 36 additions & 0 deletions docs/developer-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,42 @@ Requires Visual Studio with "Desktop development with C++" workload:
flutter run -d windows
```

## Backend sign-in settings

These live in the backend's OIDC config (`oidc/config.yaml` in the installation,
or each path in its `oidc_paths`) or, where noted, in the identity provider's
client settings. They're not in this repo, but the app depends on them. In
`oidc/config.yaml`, `scope` and `accepted_azp_list` are set per auth system,
under `auth_systems`; `allowed_frontend_origins` and
`unlisted_frontend_origin` may also be set once at the top of the file, for
every auth system in it.

- `scope` must include `openid`. Without it the identity provider issues no ID
token: sign-out can't name the session to end, and the account name and
identity fall back to the access token.
- Web sign-in from an origin other than the backend's own asks the user to
confirm on a backend page, unless the origin is listed in
`allowed_frontend_origins`. With `unlisted_frontend_origin: deny-all` it
fails with a 400 instead. An unlisted plain `http://` origin that isn't
loopback (such as `localhost` or `127.0.0.1`) fails with a 400 under either
policy. This affects a web build hosted apart from the backend and
`flutter run -d chrome`, whose port makes it a different origin: ask the
backend's operator to list the origin, or confirm the page each time.
Cancelling on that page leaves you on the backend's page, which never sends
you back; return to the app yourself.
- Native apps sign in with the backend's own `client_id` (from `/api/login`),
so their tokens carry it as `azp`. The backend accepts only the
`accepted_azp_list` values (default: that `client_id`); a deployment that
sets the list must keep that `client_id` in it, or native sign-in succeeds
at the identity provider and then every request is refused.
- In the identity provider's client settings, the web app's origin must be
allowed for cross-origin requests (Keycloak: the client's **Web Origins**,
with the app's origin, or `+` when the app is served from the backend's
origin). On web, token refresh is a browser request straight to the
provider's token endpoint. Without that setting, every refresh fails as a
network error, the session is never renewed, and it ends only when the
backend refuses a request with an expired token.

## Troubleshooting

### Analyzer errors that don't match the code
Expand Down
2 changes: 1 addition & 1 deletion lib/soliplex_frontend.dart
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,6 @@ export 'src/modules/auth/auth_providers.dart'
export 'src/modules/auth/inactivity_logout_storage.dart'
show InactivityLogoutFlagStorage, LocalInactivityLogoutFlagStorage;
export 'src/modules/auth/platform/callback_service.dart'
show CallbackParamsCapture, clearCallbackUrl;
show CallbackParams, CallbackParamsCapture, clearCallbackUrl;
export 'src/modules/auth/consent_notice.dart' show ConsentNotice;
export 'src/modules/auth/server_manager.dart' show ServerManager;
13 changes: 11 additions & 2 deletions lib/src/modules/auth/platform/callback_params.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@ class WebCallbackSuccess extends CallbackParams {
final int? expiresIn;

/// OIDC ID Token. Required as `id_token_hint` for RP-Initiated
/// Logout to deterministically end the IdP SSO session. Null until
/// the BFF includes `id_token` in the callback redirect.
/// Logout to deterministically end the IdP SSO session. Null when the
/// callback carries no ID token: the backend predates v0.82.3 / v0.83.3, its
/// `scope` lacks `openid`, or the provider returned none.
final String? idToken;

@override
Expand All @@ -42,6 +43,14 @@ class WebCallbackError extends CallbackParams {
String toString() => 'WebCallbackError(error: $error)';
}

/// A web sign-in callback whose query could not be decoded.
class WebCallbackMalformed extends CallbackParams {
const WebCallbackMalformed();

@override
String toString() => 'WebCallbackMalformed()';
}

/// No callback parameters detected.
class NoCallbackParams extends CallbackParams {
const NoCallbackParams();
Expand Down
90 changes: 73 additions & 17 deletions lib/src/modules/auth/platform/callback_params_parser.dart
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import 'package:soliplex_logging/soliplex_logging.dart';

import 'callback_params.dart';

final Logger _logger = LogManager.instance.getLogger('soliplex.auth_callback');

/// Parses OAuth callback parameters from URL query params.
///
/// Returns [WebCallbackError] if an `error` key is present,
/// [WebCallbackSuccess] if a token is found (checks both `token`
/// and `access_token` keys), or [NoCallbackParams] otherwise.
/// [WebCallbackSuccess] if a token is found (checks the `token` key), or
/// [NoCallbackParams] otherwise.
CallbackParams parseCallbackParams(Map<String, String> params) {
if (params.isEmpty) return const NoCallbackParams();

Expand All @@ -16,7 +20,7 @@ CallbackParams parseCallbackParams(Map<String, String> params) {
);
}

final accessToken = params['token'] ?? params['access_token'];
final accessToken = params['token'];
if (accessToken != null) {
return WebCallbackSuccess(
accessToken: accessToken,
Expand All @@ -29,26 +33,78 @@ CallbackParams parseCallbackParams(Map<String, String> params) {
return const NoCallbackParams();
}

/// Extracts query parameters from URL search string and hash fragment.
///
/// Checks [search] first (standard `?key=val`), then falls back to
/// hash-based query params (`#/path?key=val`).
Map<String, String> extractQueryParams({
/// The sign-in callback route, where the backend puts the tokens after a `?`.
const authCallbackHash = '#/auth/callback';

/// The query of a sign-in callback [hash], or `null` when [hash] is not
/// `#/auth/callback?…`.
String? callbackQueryFromHash(String hash) {
const prefix = '$authCallbackHash?';
return hash.startsWith(prefix) ? hash.substring(prefix.length) : null;
}

/// The URL to replace the page's with on a page load, so it starts with no
/// query, not in the address bar ([search]) and not in the hash route, or
/// `null` when it carries none. The callback query holds tokens; any other
/// query is set by in-app navigation, so a page load starts with no query: an
/// outside link can't supply one that way. It starts with [origin], so a
/// [pathname] that reads as another host (`//evil.example/`) cannot make it
/// cross-origin.
String? urlWithoutQueries({
required String origin,
required String pathname,
required String search,
required String hash,
}) {
if (search.isNotEmpty) {
return Uri.splitQueryString(search.substring(1));
}
final queryStart = hash.indexOf('?');
if (search.isEmpty && queryStart == -1) return null;
final route = queryStart == -1 ? hash : hash.substring(0, queryStart);
return '$origin$pathname$route';
}

if (hash.isNotEmpty) {
final queryIndex = hash.indexOf('?');
if (queryIndex != -1) {
return Uri.splitQueryString(hash.substring(queryIndex + 1));
}
/// A captured sign-in callback, and whether it was still in the URL because
/// the `web/index.html` script is missing or did not run.
typedef CapturedCallback = ({CallbackParams params, bool scriptMissing});

/// Captures the sign-in callback from the query `web/index.html` stashed
/// ([stashedQuery]), falling back to the current URL's [hash].
CapturedCallback captureCallback({
required String? stashedQuery,
required String hash,
}) {
if (stashedQuery != null) {
return (
params: _parseQuery(stashedQuery),
scriptMissing: false,
);
}
final query = callbackQueryFromHash(hash);
if (query == null) {
return (params: const NoCallbackParams(), scriptMissing: false);
}
return (
params: _parseQuery(query),
scriptMissing: true,
);
}

return {};
/// Parses a callback [query], or returns [WebCallbackMalformed] when its
/// percent encoding or UTF-8 is malformed, so a crafted link cannot stop the
/// app from starting.
CallbackParams _parseQuery(String query) {
try {
return parseCallbackParams(Uri.splitQueryString(query));
} catch (e, st) {
if (e is! ArgumentError && e is! FormatException) rethrow;
// `describeFailure` drops the input a failure carries, per the logging
// rule in CLAUDE.md.
_logger.warning(
'Ignoring a sign-in callback with a malformed query',
attributes: {'failure': describeFailure(e)},
stackTrace: st,
);
return const WebCallbackMalformed();
}
}

int? _parseIntOrNull(String? value) {
Expand Down
6 changes: 4 additions & 2 deletions lib/src/modules/auth/platform/callback_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ export 'callback_params.dart';
abstract final class CallbackParamsCapture {
/// Capture callback params from current URL.
///
/// On web, extracts tokens from URL query params.
/// On web, reads the tokens `web/index.html` moved out of the URL, or the
/// URL itself when that script is missing.
/// On native, returns [NoCallbackParams].
static CallbackParams captureNow() => impl.captureCallbackParamsNow();
}

/// Clears OAuth callback parameters from the browser URL.
///
/// On web, removes tokens from the URL and browser history.
/// On web, removes every query from the page URL on load, in the address bar
/// and in the hash route, including the sign-in callback's tokens.
/// On native, this is a no-op.
void clearCallbackUrl() => impl.clearCallbackUrl();
Loading
Loading