feat(oidc): halkyon_oidc_flutter, the device's half of signing in with OpenID Connect (ADR 0023)

OidcBrowser opens the page of an attempt the server began (OidcStart),
waits until the browser comes back by an OidcReturn (the app's scheme,
a verified https address, or the loopback) and returns the provider's
code (OidcCode), its refusal (OidcDenied), or nothing when the person
closed the browser. It checks the returned state against the attempt's,
as the server never compares it; a wrong state, neither one code nor
one refusal, no browser or a failed way back are OidcCallbackExceptions.

halkyon_app's LoopbackWebAuth moves in with its tests, relicensed from
AGPL v3 to BSD-3-Clause by its author, the developer; GitHub's sign-in
keeps using it from there until it ends. The package is BSD-3-Clause,
publish_to: none, as the module (ADR 0023, D2, D3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-23 23:46:52 +02:00
commit 3cb2000927
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
22 changed files with 706 additions and 42 deletions

View file

@ -13,7 +13,7 @@ Halkyon's apps and the packages they share, in one repository and one pub worksp
- `halkyon_app`: every app's shell on the module's client (ADR 0014): the instances a device keeps accounts on and the first start, signing in and switching person, the gates building an app's dependencies per instance and per person, the sync service and where it stands, the account section of an app's settings (`AccountTiles`), About with the instance's source link (`showAppAbout`), the language every app picks (`resolveAppLocale`) and countries' and subdivisions' names (Unicode CLDR's, bundled), with texts that name the app. - `halkyon_app`: every app's shell on the module's client (ADR 0014): the instances a device keeps accounts on and the first start, signing in and switching person, the gates building an app's dependencies per instance and per person, the sync service and where it stands, the account section of an app's settings (`AccountTiles`), About with the instance's source link (`showAppAbout`), the language every app picks (`resolveAppLocale`) and countries' and subdivisions' names (Unicode CLDR's, bundled), with texts that name the app.
- `halkyon_core` (low-level primitives in pure Dart: ids, a logger, `SerialQueue`, `CoalescingRunner`) and `halkyon_interop` (contracts between apps, pure Dart). - `halkyon_core` (low-level primitives in pure Dart: ids, a logger, `SerialQueue`, `CoalescingRunner`) and `halkyon_interop` (contracts between apps, pure Dart).
- `halkyon_keys` (pure Dart on libsodium, ADR 0017): an account's keys on the device, the password's Argon2id and SRP's x, the key pairs and their wrapping, the recovery phrase, data keys, content sealed and signed. SRP-6a's two halves are the module's (`halkyon_base_server`'s `base.dart`, `halkyon_base_client`'s `srp.dart`), as are TOTP and the recovery codes. `sodium`'s build hook compiles libsodium, so every machine that builds or tests needs a C compiler and `make`. - `halkyon_keys` (pure Dart on libsodium, ADR 0017): an account's keys on the device, the password's Argon2id and SRP's x, the key pairs and their wrapping, the recovery phrase, data keys, content sealed and signed. SRP-6a's two halves are the module's (`halkyon_base_server`'s `base.dart`, `halkyon_base_client`'s `srp.dart`), as are TOTP and the recovery codes. `sodium`'s build hook compiles libsodium, so every machine that builds or tests needs a C compiler and `make`.
- `halkyon_oidc_server`, `halkyon_oidc_client` (ADR 0023): signing in with OpenID Connect, with a SimpleLogin preset, as a Serverpod module that halkyon_base builds on. Nothing of Halkyon's is in it, so that others can use it with Serverpod: under BSD-3-Clause, Serverpod's own licence, where the other packages are AGPL; `publish_to: none` until it is published to pub.dev, which is asked again first. - `halkyon_oidc_server`, `halkyon_oidc_client` (ADR 0023): signing in with OpenID Connect, with a SimpleLogin preset, as a Serverpod module that halkyon_base builds on; `halkyon_oidc_flutter`, the device's half: the provider's page opened, the way back caught (the app's scheme, or the loopback on Linux and Windows, `LoopbackWebAuth`) and its state checked. Nothing of Halkyon's is in it, so that others can use it with Serverpod: under BSD-3-Clause, Serverpod's own licence, where the other packages are AGPL; `publish_to: none` until it is published to pub.dev, which is asked again first.
- **`deploy/`**: the instance deploy at the root, learn.halkyon.eu's today (Learn's `AGENTS.md`, *CI and deploys*), where other services' compose files are to join Learn's. **`snap/`**: Learn's snap, built from the root, which its build needs whole. **`docs/adr/`**: the decision records of every app and package, one numbering. **`tool/`**: the repository's tools (below). - **`deploy/`**: the instance deploy at the root, learn.halkyon.eu's today (Learn's `AGENTS.md`, *CI and deploys*), where other services' compose files are to join Learn's. **`snap/`**: Learn's snap, built from the root, which its build needs whole. **`docs/adr/`**: the decision records of every app and package, one numbering. **`tool/`**: the repository's tools (below).
- **One workspace, one lock**: the root `pubspec.yaml` lists every package, `pubspec.lock` holds one version of each dependency for all of them, and its `dependency_overrides` hold for every app (the offline sync fork, Learn's `AGENTS.md`; `cli_util`, below). **Serverpod's version moves as one**: every package pins Serverpod's packages to the same version (4.0.4), the module's and Todo's too, together with the CLI version CI generates code with (`SERVERPOD_VERSION` in `.forgejo/workflows/ci.yaml`) and the sync packages Learn's server depends on. - **One workspace, one lock**: the root `pubspec.yaml` lists every package, `pubspec.lock` holds one version of each dependency for all of them, and its `dependency_overrides` hold for every app (the offline sync fork, Learn's `AGENTS.md`; `cli_util`, below). **Serverpod's version moves as one**: every package pins Serverpod's packages to the same version (4.0.4), the module's and Todo's too, together with the CLI version CI generates code with (`SERVERPOD_VERSION` in `.forgejo/workflows/ci.yaml`) and the sync packages Learn's server depends on.

View file

@ -80,7 +80,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **Linking keeps the person** (`GitHubUpgradeEndpoint`, `requireLogin`): the GitHub ID joins the signed-in anonymous person, who becomes identified and loses the anonymous identity, in one transaction. **Signing in** (`GitHubSignInEndpoint`) only reaches people who have a GitHub account already, and only without a session: Serverpod refuses to issue another person's session inside a signed-in call. Refusals are `GitHubSignInRefusedException` with a reason. - **Linking keeps the person** (`GitHubUpgradeEndpoint`, `requireLogin`): the GitHub ID joins the signed-in anonymous person, who becomes identified and loses the anonymous identity, in one transaction. **Signing in** (`GitHubSignInEndpoint`) only reaches people who have a GitHub account already, and only without a session: Serverpod refuses to issue another person's session inside a signed-in call. Refusals are `GitHubSignInRefusedException` with a reason.
- **Switching devices' person** (halkyon_app's `switchToGitHub` and `useSession`, on whether the device holds content as Learn counts it, `PersonDependencies.holdsContent`: its studysets, Recently deleted's too): swap the session directly, never through "signed out" (which would leave the device asking how to start), then delete the account left behind if it was anonymous (nobody could sign into it again), never an identified one. Someone identified is never offered a switch: an address or GitHub account that is another account's is said to be so. - **Switching devices' person** (halkyon_app's `switchToGitHub` and `useSession`, on whether the device holds content as Learn counts it, `PersonDependencies.holdsContent`: its studysets, Recently deleted's too): swap the session directly, never through "signed out" (which would leave the device asking how to start), then delete the account left behind if it was anonymous (nobody could sign into it again), never an identified one. Someone identified is never offered a switch: an address or GitHub account that is another account's is said to be so.
- **Email keeps only a keyed hash of the address** (ADR 0010, decided 2026-10-15): our own slim login (halkyon_base's `email.dart`) on Serverpod's code challenges and rate limiter, as Serverpod's email sign-in keeps the address in plain text and passwords. `email_identity` holds HMAC-SHA256 of the address in lower case, keyed with `emailSignInPepper`; the instance mails only an address someone has just typed, and the device remembers the address it signed in with (`SignInAddresses`, `sign_in.json` in the person's folder). A code by mail signs nobody in since ADR 0017 (build 22): it proves an address for a password's setup, a sign-up, a setup never finished, the resets and deleting an account whose second factor doesn't work, each code asked for one of those purposes (`EmailCodePurpose`), taken by it alone, its mail saying what it would do and to type it only in the app on this instance. Eight Crockford characters (halkyon_base's `short_code.dart`), 15 minutes, 3 tries, up to 3 valid at once for an address (a fourth voids the oldest); at most 3 codes an hour per address and network, 10 per address from every network and 10 per network. `emailCode.sendCode` (without a session) answers alike for every address. Refusals are `EmailSignInRefusedException` with a reason. Codes go by mail over SMTP (halkyon_base's `code_mail.dart`, naming the app the host configures, pub.dev's `mailer`, TLS only), through the service the passwords name (`emailSmtpHost`, `emailSmtpPort`, `emailSmtpUser`, `emailSmtpPassword`, `emailFrom`; in production `EMAIL_SMTP_…` and `EMAIL_FROM` in `deploy/.env`; learn.halkyon.eu's is Proton's), as plain text with the code and what it is for in the subject; without one, into the server's log in development, and elsewhere there is no email sign-in (`emailSignIn` in halkyon_base's `instance.basics()`, which the app asks). Nothing logs an address or a code (a failed mail logs its error's type), and never attach a handler to `package:logging`'s root logger, through which `mailer` would. - **Email keeps only a keyed hash of the address** (ADR 0010, decided 2026-10-15): our own slim login (halkyon_base's `email.dart`) on Serverpod's code challenges and rate limiter, as Serverpod's email sign-in keeps the address in plain text and passwords. `email_identity` holds HMAC-SHA256 of the address in lower case, keyed with `emailSignInPepper`; the instance mails only an address someone has just typed, and the device remembers the address it signed in with (`SignInAddresses`, `sign_in.json` in the person's folder). A code by mail signs nobody in since ADR 0017 (build 22): it proves an address for a password's setup, a sign-up, a setup never finished, the resets and deleting an account whose second factor doesn't work, each code asked for one of those purposes (`EmailCodePurpose`), taken by it alone, its mail saying what it would do and to type it only in the app on this instance. Eight Crockford characters (halkyon_base's `short_code.dart`), 15 minutes, 3 tries, up to 3 valid at once for an address (a fourth voids the oldest); at most 3 codes an hour per address and network, 10 per address from every network and 10 per network. `emailCode.sendCode` (without a session) answers alike for every address. Refusals are `EmailSignInRefusedException` with a reason. Codes go by mail over SMTP (halkyon_base's `code_mail.dart`, naming the app the host configures, pub.dev's `mailer`, TLS only), through the service the passwords name (`emailSmtpHost`, `emailSmtpPort`, `emailSmtpUser`, `emailSmtpPassword`, `emailFrom`; in production `EMAIL_SMTP_…` and `EMAIL_FROM` in `deploy/.env`; learn.halkyon.eu's is Proton's), as plain text with the code and what it is for in the subject; without one, into the server's log in development, and elsewhere there is no email sign-in (`emailSignIn` in halkyon_base's `instance.basics()`, which the app asks). Nothing logs an address or a code (a failed mail logs its error's type), and never attach a handler to `package:logging`'s root logger, through which `mailer` would.
- **On Linux and Windows the browser half is the app's own** (`LoopbackWebAuth`, halkyon_app): the system browser and a loopback address (RFC 8252), put in place of flutter_web_auth_2's own implementation (checked with v5.1.0) before each sign-in. The package's front ends every pending sign-in when the app is resumed, and on the desktop the window regaining focus resumes it, so its loopback server closed before GitHub answered. Ours waits for GitHub, the profile's Cancel (shown only there), choosing another tab, a change of person, or 5 minutes; the regression test drives the package's front through a focus change. Remove it once the package stops ending sign-ins on focus there. Serverpod (v4.0.3) reports every cancelled browser round trip as an unknown error, so the gateway knows a cancel only by having asked for it; a cancel on GitHub's own page, or leaving the browser on Android, still shows "Couldn't sign in with GitHub." - **On Linux and Windows the browser half is the app's own** (`LoopbackWebAuth`, halkyon_oidc_flutter since 23 Oct 2026, where SimpleLogin's sign-in uses it too, relicensed BSD-3-Clause by its author): the system browser and a loopback address (RFC 8252), put in place of flutter_web_auth_2's own implementation (checked with v5.1.0) before each sign-in. The package's front ends every pending sign-in when the app is resumed, and on the desktop the window regaining focus resumes it, so its loopback server closed before GitHub answered. Ours waits for GitHub, the profile's Cancel (shown only there), choosing another tab, a change of person, or 5 minutes; the regression test drives the package's front through a focus change. Remove it once the package stops ending sign-ins on focus there. Serverpod (v4.0.3) reports every cancelled browser round trip as an unknown error, so the gateway knows a cancel only by having asked for it; a cancel on GitHub's own page, or leaving the browser on Android, still shows "Couldn't sign in with GitHub."
- **Authorization lives in endpoint code**; Serverpod has no row-level security. Load content filtered by the session's `authUserId`, never by an owner ID the client sends. A row another person owns must be indistinguishable from a missing one. Features involving other people require the `identified` scope, declared via `requiredScopes`, never checked ad hoc. - **Authorization lives in endpoint code**; Serverpod has no row-level security. Load content filtered by the session's `authUserId`, never by an owner ID the client sends. A row another person owns must be indistinguishable from a missing one. Features involving other people require the `identified` scope, declared via `requiredScopes`, never checked ad hoc.
- **The reference is `DevicesEndpoint`** (`apps/learn/halkyon_learn_server/lib/src/auth/`) with its tests: the owner check sits in the same query (`WHERE "id" = $1 AND "authUserId" = $2`), zero rows means `NotFoundException` (shared, `lib/src/common/`), and every rule has a test that asserts the refusal. Each query carries its SQL as a comment. - **The reference is `DevicesEndpoint`** (`apps/learn/halkyon_learn_server/lib/src/auth/`) with its tests: the owner check sits in the same query (`WHERE "id" = $1 AND "authUserId" = $2`), zero rows means `NotFoundException` (shared, `lib/src/common/`), and every rule has a test that asserts the refusal. Each query carries its SQL as a comment.
- **Serverpod's own helpers aren't guarded**: `ServerSideSessions.revokeSession` deletes by id alone, and `listSessions` without `authUserId` returns everyone's sessions. Never pass them anything a client sent. - **Serverpod's own helpers aren't guarded**: `ServerSideSessions.revokeSession` deletes by id alone, and `listSessions` without `authUserId` returns everyone's sessions. Never pass them anything a client sent.

View file

@ -1,7 +1,13 @@
# ADR 0023: Signing in with OpenID Connect, a module others can use # ADR 0023: Signing in with OpenID Connect, a module others can use
- **Status:** decided 23 Oct 2026, by the developer (D1–D4); the design below is built - **Status:** decided 23 Oct 2026, by the developer (D1–D4); the design below is built
as ADR 0020's step 2, whose D12 and D13 it carries. as ADR 0020's step 2, whose D12 and D13 it carries. `halkyon_oidc_flutter` built with
step 4 (23 Oct, night): `OidcBrowser` opens an `OidcStart`'s page and returns the
code (`OidcCode`), the provider's refusal (`OidcDenied`) or nothing (closed), the
state checked on the device, as the server never compares it; the way back an
`OidcReturn` (the app's scheme, a verified https address, the loopback). halkyon_app's
`LoopbackWebAuth` moved in, relicensed from AGPL v3 to BSD-3-Clause by its author, the
developer, and serves GitHub's sign-in from there until it ends.
- **Builds on:** ADR 0014 (halkyon_base, the first Serverpod module), ADR 0020 (signing in - **Builds on:** ADR 0014 (halkyon_base, the first Serverpod module), ADR 0020 (signing in
with SimpleLogin only). with SimpleLogin only).

View file

@ -59,7 +59,8 @@ Written as the apps are: Dart 3.13's primary constructors, Material from
fails a sign-out. fails a sign-out.
- **Signing in** (`lib/src/auth/`): `AuthRepository` on an `AuthGateway`; - **Signing in** (`lib/src/auth/`): `AuthRepository` on an `AuthGateway`;
`ServerpodAuthGateway` signs in anonymously and with GitHub (the system `ServerpodAuthGateway` signs in anonymously and with GitHub (the system
browser and a loopback address on Linux and Windows, `LoopbackWebAuth`), browser and a loopback address on Linux and Windows, halkyon_oidc_flutter's
`LoopbackWebAuth`),
through the instance's `halkyon_base`, and puts a session granted elsewhere through the instance's `halkyon_base`, and puts a session granted elsewhere
in place (`useSession`); `SessionBloc`, `GitHubSignInBloc` and in place (`useSession`); `SessionBloc`, `GitHubSignInBloc` and
`SignInWaysCubit`; `SignedOutMark`, `SignInAddresses`, `PersonFolders`. `SignInWaysCubit`; `SignedOutMark`, `SignInAddresses`, `PersonFolders`.

View file

@ -35,7 +35,6 @@ export 'src/auth/bloc/session_bloc.dart';
export 'src/auth/bloc/sign_in_ways_cubit.dart'; export 'src/auth/bloc/sign_in_ways_cubit.dart';
export 'src/auth/data/auth_gateway.dart'; export 'src/auth/data/auth_gateway.dart';
export 'src/auth/data/auth_repository.dart'; export 'src/auth/data/auth_repository.dart';
export 'src/auth/data/loopback_web_auth.dart';
export 'src/auth/data/serverpod_auth_gateway.dart'; export 'src/auth/data/serverpod_auth_gateway.dart';
export 'src/auth/data/sign_in_addresses.dart'; export 'src/auth/data/sign_in_addresses.dart';
export 'src/auth/data/signed_out_mark.dart'; export 'src/auth/data/signed_out_mark.dart';

View file

@ -4,7 +4,6 @@ import 'dart:io';
import 'package:flutter_web_auth_2_platform_interface/flutter_web_auth_2_platform_interface.dart'; import 'package:flutter_web_auth_2_platform_interface/flutter_web_auth_2_platform_interface.dart';
import 'package:halkyon_app/src/auth/data/auth_gateway.dart'; import 'package:halkyon_app/src/auth/data/auth_gateway.dart';
import 'package:halkyon_app/src/auth/data/loopback_web_auth.dart';
import 'package:halkyon_app/src/auth/model/auth_status.dart'; import 'package:halkyon_app/src/auth/model/auth_status.dart';
import 'package:halkyon_app/src/auth/model/github_link_outcome.dart'; import 'package:halkyon_app/src/auth/model/github_link_outcome.dart';
import 'package:halkyon_app/src/auth/model/github_sign_in.dart'; import 'package:halkyon_app/src/auth/model/github_sign_in.dart';
@ -14,6 +13,8 @@ import 'package:halkyon_app/src/base/base_client.dart';
import 'package:halkyon_base_client/halkyon_base_client.dart' import 'package:halkyon_base_client/halkyon_base_client.dart'
show GitHubSignInRefusal, GitHubSignInRefusedException; show GitHubSignInRefusal, GitHubSignInRefusedException;
import 'package:halkyon_base_client/halkyon_base_client.dart' as base_client; import 'package:halkyon_base_client/halkyon_base_client.dart' as base_client;
import 'package:halkyon_oidc_flutter/halkyon_oidc_flutter.dart'
show LoopbackWebAuth;
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:serverpod_auth_idp_flutter/serverpod_auth_idp_flutter.dart'; import 'package:serverpod_auth_idp_flutter/serverpod_auth_idp_flutter.dart';

View file

@ -29,8 +29,9 @@ dependencies:
# The account's private keys, once unlocked, in the system's keyring # The account's private keys, once unlocked, in the system's keyring
# (ADR 0017), as Serverpod keeps sessions. # (ADR 0017), as Serverpod keeps sessions.
flutter_secure_storage: ^10.0.0 flutter_secure_storage: ^10.0.0
# LoopbackWebAuth takes the place of the package's own browser half on # GitHub's sign-in goes through the package's front, so LoopbackWebAuth
# Linux and Windows. # (halkyon_oidc_flutter) takes the place of its browser half on Linux and
# Windows.
flutter_web_auth_2_platform_interface: ^5.0.0 flutter_web_auth_2_platform_interface: ^5.0.0
halkyon_base_client: halkyon_base_client:
path: ../halkyon_base_client path: ../halkyon_base_client
@ -40,6 +41,8 @@ dependencies:
# hook compiles, so every build of an app needs a C compiler and make. # hook compiles, so every build of an app needs a C compiler and make.
halkyon_keys: halkyon_keys:
path: ../halkyon_keys path: ../halkyon_keys
halkyon_oidc_flutter:
path: ../halkyon_oidc_flutter
halkyon_ui: halkyon_ui:
path: ../halkyon_ui path: ../halkyon_ui
# A client of halkyon_base alone can be given a fake one in tests. # A client of halkyon_base alone can be given a fake one in tests.
@ -63,7 +66,6 @@ dependencies:
# opening it on each isolate the account's keys are worked on. # opening it on each isolate the account's keys are worked on.
sodium: 4.1.0+1 sodium: 4.1.0+1
url_launcher: ^6.3.2 url_launcher: ^6.3.2
window_to_front: ^0.0.4
dev_dependencies: dev_dependencies:
fake_async: ^1.3.3 fake_async: ^1.3.3

View file

@ -0,0 +1,31 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
/pubspec.lock
**/doc/api/
.dart_tool/
.flutter-plugins-dependencies
/build/
/coverage/

View file

@ -0,0 +1,5 @@
## 0.1.0
* The browser's half of a sign-in: the provider's page opened, the way back by the
app's scheme, a verified https address or the loopback, its state checked, the code
or the provider's refusal returned.

View file

@ -0,0 +1,28 @@
BSD 3-Clause License
Copyright (c) 2026, the Project Halkyon authors
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View file

@ -0,0 +1,75 @@
# halkyon_oidc_flutter
The device's half of halkyon_oidc (`halkyon_oidc_server`, `halkyon_oidc_client`),
signing in with OpenID Connect for Serverpod v4. It opens the provider's page for an
attempt the server began, waits until the browser comes back, and checks that it came
back for that attempt. The code it returns goes back to the server, which ends the
sign-in. Halkyon signs in with it (ADR 0020, ADR 0023); nothing of Halkyon's is in it.
## Using it
```dart
final browser = OidcBrowser(loopback: LoopbackWebAuth(appName: 'My App'));
// The app's endpoint on OidcIdpBaseEndpoint (halkyon_oidc_server's README).
final start = await client.signIn.begin(provider: 'keycloak');
switch (await browser.open(start, const OidcReturn.scheme('my.app'))) {
case null:
// The person closed the browser.
case OidcDenied(:final error):
// The person said no at the provider (`access_denied`).
case OidcCode(:final code):
final session = await client.signIn.login(attempt: start.attempt, code: code);
}
```
`open` throws an `OidcCallbackException` when the browser came back for another
sign-in (`wrongState`), with neither one code nor one refusal (`malformed`), when
there's no browser (`noBrowser`), or when the way back failed otherwise (`failed`).
The state is checked here, on the device: the server keeps the attempt's nonce, which
ties the code to the attempt, but never compares the state.
## The way back
Pick one per sign-in, before opening the browser: each must match the redirect the
server's provider names, or the address its forwarding page sends the browser on to
(`addOidcCallbackForwarding`).
- **`OidcReturn.scheme('my.app')`** on Android, iOS and macOS. The system's browser
sheet catches the scheme, even after a redirect: Chrome's Auth Tab (Chrome 132 on),
a Custom Tab after the person's click on the consent page in other browsers, and
`ASWebAuthenticationSession`. Android needs flutter_web_auth_2's `CallbackActivity`
with the scheme, for the browsers without Auth Tab:
```xml
<activity android:name="com.linusu.flutter_web_auth_2.CallbackActivity"
android:exported="true" android:taskAffinity="">
<intent-filter android:label="flutter_web_auth_2">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="my.app" />
</intent-filter>
</activity>
```
- **`OidcReturn.loopback(port: 43823, path: '/oidc-callback')`** on Linux and Windows:
`LoopbackWebAuth` opens the system browser and waits on `127.0.0.1` alone (RFC 8252,
7.3), five minutes at most, until `cancel`. It takes the place of flutter_web_auth_2's
own, which ends the wait whenever the window regains focus. Give each app its own
port.
- **`OidcReturn.https(host: 'example.org', path: '/oidc-callback')`** where the system
hands the address to the app alone: Android's App Links with Chrome's Auth Tab (the
host's `assetlinks.json`), iOS v17.4 and macOS v14.4 on with associated domains
(`webcredentials:`). It protects only where nothing else can come back: a hostile app
that begins a sign-in of its own may ask for any way back the server still forwards
to. On Android it fails where the app isn't verified, as a debug build isn't, and in
browsers without Auth Tab (Firefox, as of v156).
A code caught on its way is worthless without the attempt's ID, which only the device
holds, so the scheme and the loopback are safe for the sign-ins the app begins itself.
## Licence
BSD-3-Clause, as Serverpod's own (ADR 0023, D2).

View file

@ -0,0 +1,9 @@
# The workspace's lint rules (see ../../analysis_options.yaml).
include: ../../analysis_options.yaml
analyzer:
exclude:
- build/**
formatter:
trailing_commas: preserve

View file

@ -0,0 +1,12 @@
/// The device's half of signing in with OpenID Connect on halkyon_oidc:
/// the provider's page opened in the browser, the way back caught and
/// checked against the sign-in that began it.
library;
export 'package:halkyon_oidc_client/halkyon_oidc_client.dart'
show OidcRefusal, OidcRefusedException, OidcStart, UuidValue;
export 'src/loopback_web_auth.dart';
export 'src/oidc_browser.dart';
export 'src/oidc_callback.dart';
export 'src/oidc_return.dart';

View file

@ -0,0 +1,151 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter_web_auth_2_platform_interface/flutter_web_auth_2_platform_interface.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:window_to_front/window_to_front.dart';
/// The browser half of signing in on Linux and Windows: opens the provider's
/// page in the system browser and waits on a loopback address until the
/// browser comes back (RFC 8252, section 7.3).
///
/// It takes the place of flutter_web_auth_2's own implementation, which the
/// package's front ends whenever the app is resumed. That suits mobile, where
/// coming back to the app without finishing means giving up, but on the
/// desktop the app's window regaining focus resumes it, and the browser came
/// back to a closed port. `OidcBrowser` calls it directly; a sign-in that
/// goes through the package's front (Serverpod's own, with PKCE) puts it in
/// place as `FlutterWebAuth2Platform.instance`. Remove this class once the
/// package no longer ends the sign-in on Linux and Windows then.
///
/// The wait ends when the browser comes back, on [cancel], when another
/// sign-in starts, or after [_timeout].
class LoopbackWebAuth({
/// The app's name, as the browser's page says to go back to it.
required final String appName,
final Future<bool> Function(Uri url) _launch = launchUrl,
final Future<void> Function() _bringToFront = WindowToFront.activate,
final Duration _timeout = const Duration(minutes: 5),
}) extends FlutterWebAuth2Platform {
HttpServer? _server;
Completer<String>? _pending;
/// Opens [url] in the browser and returns the URL the browser comes back
/// to. [callbackUrlScheme] is the whole redirect URI, on 127.0.0.1 with a
/// port; only its path ends the wait. [options] are the package's and
/// don't apply.
@override
Future<String> authenticate({
required String url,
required String callbackUrlScheme,
required Map<String, dynamic> options,
}) async {
final callback = Uri.parse(callbackUrlScheme);
if (callback.scheme != 'http' ||
callback.host != InternetAddress.loopbackIPv4.address ||
!callback.hasPort ||
callback.path.isEmpty) {
throw ArgumentError.value(
callbackUrlScheme,
'callbackUrlScheme',
'must be a path on http://127.0.0.1 with a port',
);
}
await cancel();
// The loopback address only: nothing but this device can answer.
final server = _server = await HttpServer.bind(
InternetAddress.loopbackIPv4,
callback.port,
);
final pending = _pending = Completer<String>()..future.ignore();
server.listen((request) => _answer(request, callback.path, pending));
try {
if (!await _launch(Uri.parse(url))) {
throw PlatformException(
code: 'FAILED',
message: "Couldn't open the browser.",
);
}
final result = await pending.future.timeout(_timeout);
try {
await _bringToFront();
} on Exception catch (error, st) {
// The sign-in worked; the person switches back by themselves.
log(
"Couldn't bring the window to the front",
name: 'Auth',
error: error,
stackTrace: st,
);
}
return result;
} finally {
await server.close();
if (identical(_server, server)) {
_server = null;
_pending = null;
}
}
}
/// Does nothing: the package's front calls it whenever the app is resumed,
/// which on the desktop includes the window regaining focus. [cancel] ends
/// a sign-in instead.
@override
Future<void> clearAllDanglingCalls() async {}
/// Stops waiting: the sign-in in progress ends as cancelled, the way the
/// package reports it. Does nothing when none is in progress.
Future<void> cancel() async {
final pending = _pending;
final server = _server;
_pending = null;
_server = null;
if (pending != null && !pending.isCompleted) {
pending.completeError(
PlatformException(code: 'CANCELED', message: 'User canceled login'),
);
}
await server?.close(force: true);
}
/// Answers the browser: the first request for the callback's path ends the
/// wait, anything else is not found.
Future<void> _answer(
HttpRequest request,
String callbackPath,
Completer<String> pending,
) async {
final response = request.response;
if (request.uri.path == callbackPath && !pending.isCompleted) {
pending.complete(request.requestedUri.toString());
response
..headers.contentType = ContentType.html
..write(_landingPage(appName));
} else {
response.statusCode = HttpStatus.notFound;
}
try {
await response.close();
} on IOException {
// The browser went away before the answer; nothing is left to tell it.
}
}
}
/// What the browser shows once it has come back to [app].
String _landingPage(String app) {
final name = const HtmlEscape().convert(app);
return '''
<!DOCTYPE html>
<html lang="en">
<meta charset="utf-8">
<title>$name</title>
<p>You can close this tab and return to $name.</p>
</html>
''';
}

View file

@ -0,0 +1,76 @@
import 'dart:async';
import 'package:flutter/services.dart';
import 'package:flutter_web_auth_2/flutter_web_auth_2.dart';
import 'package:halkyon_oidc_client/halkyon_oidc_client.dart';
import 'package:halkyon_oidc_flutter/src/loopback_web_auth.dart';
import 'package:halkyon_oidc_flutter/src/oidc_callback.dart';
import 'package:halkyon_oidc_flutter/src/oidc_return.dart';
/// Opens a page in the system's browser sheet and returns the address it
/// comes back to: flutter_web_auth_2's `authenticate`.
typedef WebAuthenticate = Future<String> Function({
required String url,
required String callbackUrlScheme,
FlutterWebAuth2Options options,
});
/// The device's half of signing in with OpenID Connect: opens the
/// provider's page for an attempt the server began (`OidcStart`), waits
/// until the browser comes back, and checks that it came back for that
/// attempt. The code it returns goes to the server, with the attempt's ID,
/// to end the sign-in.
class OidcBrowser({
/// Waits on the loopback for [OidcReturn.loopback], in place of
/// flutter_web_auth_2's own; its page names the app.
required final LoopbackWebAuth loopback,
final WebAuthenticate _authenticate = FlutterWebAuth2.authenticate,
}) {
/// Opens [start]'s page and waits until the browser comes [back]: the
/// provider's code or its refusal, or null when the person closed the
/// browser, or the loopback's wait was cancelled or ran out. Throws an
/// [OidcCallbackException] when it came back for another sign-in, came
/// back with neither, or the browser failed.
Future<OidcCallback?> open(OidcStart start, OidcReturn back) async {
final url = start.authorizationUrl.toString();
final String address;
try {
address = await switch (back) {
OidcSchemeReturn(:final scheme) => _authenticate(
url: url,
callbackUrlScheme: scheme,
),
OidcHttpsReturn(:final host, :final path) => _authenticate(
url: url,
callbackUrlScheme: 'https',
options: FlutterWebAuth2Options(httpsHost: host, httpsPath: path),
),
// Straight to the loopback, not through the package's front, which
// would end the wait whenever the window regains focus.
final OidcLoopbackReturn loopbackReturn => loopback.authenticate(
url: url,
callbackUrlScheme: loopbackReturn.address,
options: const {},
),
};
} on PlatformException catch (error) {
// flutter_web_auth_2's codes (checked with v5.1.0).
return switch (error.code) {
'CANCELED' => null,
'NO_BROWSER' ||
'SECURITY_EXCEPTION' => throw const OidcCallbackException(
OidcCallbackProblem.noBrowser,
),
_ => throw const OidcCallbackException(OidcCallbackProblem.failed),
};
} on TimeoutException {
return null;
}
return OidcCallback.parse(address, state: start.state);
}
/// Stops waiting on the loopback: the sign-in in progress returns nothing.
/// The system's browser sheet ends by itself when the person closes it.
Future<void> cancel() => loopback.cancel();
}

View file

@ -0,0 +1,78 @@
import 'package:equatable/equatable.dart';
/// What the browser came back with from the provider, for the sign-in that
/// began it.
sealed class const OidcCallback() with Equatable {
/// Reads [url], where the browser came back to, for the sign-in whose
/// state is [state]: the code, or the provider's refusal. Throws an
/// [OidcCallbackException] for another sign-in's state, or none
/// ([OidcCallbackProblem.wrongState]), and for anything but one code or one
/// refusal ([OidcCallbackProblem.malformed]).
factory parse(String url, {required String state}) {
final Map<String, List<String>> query;
try {
query = Uri.parse(url).queryParametersAll;
} on FormatException {
throw const OidcCallbackException(OidcCallbackProblem.malformed);
}
final states = query['state'] ?? const [];
if (states.length > 1) {
throw const OidcCallbackException(OidcCallbackProblem.malformed);
}
// The state is what ties the way back to this sign-in: the provider
// repeats it, and the server never compares it.
if (states.singleOrNull != state) {
throw const OidcCallbackException(OidcCallbackProblem.wrongState);
}
return switch ((query['code'], query['error'])) {
([final code], null) when code.isNotEmpty => OidcCode(code),
(null, [final error]) when error.isNotEmpty => OidcDenied(error),
_ => throw const OidcCallbackException(OidcCallbackProblem.malformed),
};
}
}
/// The provider's code, for the server to end the sign-in with.
final class const OidcCode(
/// Worthless without the sign-in's attempt, which only this device holds.
final String code,
) extends OidcCallback {
@override
List<Object?> get props => [code];
@override
String toString() => 'OidcCode(…)';
}
/// The provider's refusal: the person said no, or the provider couldn't go
/// on.
final class const OidcDenied(
/// OAuth's error code (`access_denied`; SimpleLogin's own is `deny`).
final String error,
) extends OidcCallback {
@override
List<Object?> get props => [error];
}
/// Why the way back from the provider can't go on.
enum OidcCallbackProblem() {
/// The browser came back for another sign-in, or for none.
wrongState,
/// It came back with neither one code nor one refusal.
malformed,
/// There's no browser to open the provider's page in.
noBrowser,
/// The browser couldn't be opened, or didn't come back as asked: the
/// system's sheet failed, or the app was ended meanwhile.
failed,
}
/// The way back from the provider failed, for [problem].
final class const OidcCallbackException(final OidcCallbackProblem problem)
implements Exception {
@override
String toString() => 'OidcCallbackException(${problem.name})';
}

View file

@ -0,0 +1,38 @@
/// How the browser comes back to this device from the provider (RFC 8252,
/// 7): the redirect the server's attempt names, or the address its
/// forwarding page sends the browser on to.
sealed class const OidcReturn() {
/// The app's own scheme (`my.app://oidc-callback`), which the system's
/// browser sheet catches: Chrome's Auth Tab or a Custom Tab on Android,
/// `ASWebAuthenticationSession` on iOS and macOS.
const factory scheme(String scheme) = OidcSchemeReturn;
/// A loopback address on this machine (`http://127.0.0.1:<port><path>`),
/// on which `OidcBrowser` waits itself: Linux and Windows.
const factory loopback({required int port, required String path}) =
OidcLoopbackReturn;
/// An https address the system hands to the app, as it is verified as
/// the app's: Android's App Links (with Chrome's Auth Tab), and iOS's and
/// macOS's associated domains (v17.4 and v14.4 on).
const factory https({required String host, required String path}) =
OidcHttpsReturn;
}
/// The way back by the app's [scheme].
final class const OidcSchemeReturn(final String scheme) extends OidcReturn;
/// The way back by the loopback, on [port] at [path].
final class const OidcLoopbackReturn({
required final int port,
required final String path,
}) extends OidcReturn {
/// The whole address the browser comes back to.
String get address => 'http://127.0.0.1:$port$path';
}
/// The way back by a verified https address, [host] and [path].
final class const OidcHttpsReturn({
required final String host,
required final String path,
}) extends OidcReturn;

View file

@ -0,0 +1,36 @@
name: halkyon_oidc_flutter
description: >-
The device's half of halkyon_oidc, signing in with OpenID Connect for
Serverpod: opens the provider's page in the system's browser sheet or the
browser, catches the way back (the app's scheme, a verified https address,
or the loopback on Linux and Windows) and checks it came back for the
sign-in that began it.
version: 0.1.0
# In the Halkyon repository until it runs on learn.halkyon.eu (ADR 0023, D3).
publish_to: none
environment:
sdk: ^3.13.0
flutter: ^3.44.4
resolution: workspace
dependencies:
equatable: ^3.0.0
flutter:
sdk: flutter
# The system's browser sheet and its way back: Chrome's Auth Tab or a
# Custom Tab on Android, ASWebAuthenticationSession on iOS and macOS.
flutter_web_auth_2: ^5.1.0
# LoopbackWebAuth takes the place of the package's own browser half on
# Linux and Windows.
flutter_web_auth_2_platform_interface: ^5.0.0
halkyon_oidc_client:
path: ../halkyon_oidc_client
# The loopback's browser, and bringing the app's window back.
url_launcher: ^6.3.2
window_to_front: ^0.0.4
dev_dependencies:
flutter_test:
sdk: flutter

View file

@ -0,0 +1,259 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:ui' show AppLifecycleState;
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_web_auth_2/flutter_web_auth_2.dart';
import 'package:flutter_web_auth_2_platform_interface/flutter_web_auth_2_platform_interface.dart';
import 'package:halkyon_oidc_flutter/halkyon_oidc_flutter.dart';
void main() {
const providerPage = 'https://id.example.org/authorize?client_id=app';
late int port;
late String callback;
late Completer<Uri> pageOpened;
late int broughtToFront;
late LoopbackWebAuth webAuth;
/// The loopback part with a fake browser that only records the page it
/// opens; [opens] is whether opening it works.
LoopbackWebAuth loopback({
bool opens = true,
Future<void> Function()? bringToFront,
Duration timeout = const Duration(minutes: 1),
}) => LoopbackWebAuth(
appName: 'My App',
launch: (url) {
pageOpened.complete(url);
return Future.value(opens);
},
bringToFront:
bringToFront ??
() {
broughtToFront++;
return Future.value();
},
timeout: timeout,
);
setUp(() async {
// A port that is free right now, so the tests never meet a running app.
final probe = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
port = probe.port;
await probe.close();
callback = 'http://127.0.0.1:$port/oidc-callback';
pageOpened = Completer();
broughtToFront = 0;
webAuth = loopback();
});
tearDown(() => webAuth.cancel());
/// Starts a sign-in as flutter_web_auth_2 hands it over.
Future<String> signIn() => webAuth.authenticate(
url: providerPage,
callbackUrlScheme: callback,
options: const {},
);
/// The browser asking this machine's [address] for [pathAndQuery], as when
/// the provider sends it back. Over a raw socket, so that the fake HTTP of
/// Flutter's test binding can't get in between.
Future<({int status, String body})> browserAsks(
String pathAndQuery, {
InternetAddress? address,
}) async {
final socket = await Socket.connect(
address ?? InternetAddress.loopbackIPv4,
port,
timeout: const Duration(seconds: 2),
);
socket.write(
'GET $pathAndQuery HTTP/1.1\r\n'
'Host: 127.0.0.1:$port\r\n'
'Connection: close\r\n\r\n',
);
final response = await utf8.decoder.bind(socket).join();
socket.destroy();
return (
status: int.parse(response.split(' ')[1]),
body: response.substring(response.indexOf('\r\n\r\n') + 4),
);
}
test(
"opens the provider's page and returns where the browser comes back to",
() async {
final result = signIn();
expect(await pageOpened.future, Uri.parse(providerPage));
final response = await browserAsks('/oidc-callback?code=c&state=s');
expect(await result, '$callback?code=c&state=s');
expect(response.status, HttpStatus.ok);
expect(response.body, contains('return to My App'));
expect(broughtToFront, 1);
},
);
test(
"keeps waiting when the app's window regains focus",
() async {
// The way the bug went: flutter_web_auth_2's front ends every pending
// sign-in when the app is resumed, and on the desktop the window
// regaining focus resumes it.
TestWidgetsFlutterBinding.ensureInitialized();
FlutterWebAuth2Platform.instance = webAuth;
final result = FlutterWebAuth2.authenticate(
url: providerPage,
callbackUrlScheme: callback,
);
await pageOpened.future;
// The browser takes the focus; then the person looks back at the app.
await _embedderReports(AppLifecycleState.inactive);
await _embedderReports(AppLifecycleState.resumed);
final response = await browserAsks('/oidc-callback?code=c&state=s');
expect(response.status, HttpStatus.ok);
expect(await result, '$callback?code=c&state=s');
},
// Elsewhere, flutter_web_auth_2 insists on a URL scheme as the callback.
skip: !(Platform.isLinux || Platform.isWindows),
);
test('a cancel ends the wait as cancelled and stops listening', () async {
final result = signIn();
await pageOpened.future;
await webAuth.cancel();
await expectLater(result, throwsA(_cancelled));
await expectLater(
browserAsks('/oidc-callback?code=c&state=s'),
throwsA(isA<SocketException>()),
);
});
test('gives up after the timeout and stops listening', () async {
webAuth = loopback(timeout: const Duration(milliseconds: 50));
await expectLater(signIn(), throwsA(isA<TimeoutException>()));
await expectLater(
browserAsks('/oidc-callback?code=c&state=s'),
throwsA(isA<SocketException>()),
);
});
test('a second sign-in ends the first as cancelled', () async {
final first = signIn();
await pageOpened.future;
pageOpened = Completer();
final second = signIn();
await expectLater(first, throwsA(_cancelled));
await pageOpened.future;
await browserAsks('/oidc-callback?code=c&state=s');
expect(await second, '$callback?code=c&state=s');
});
test('answers other paths with "not found" and keeps waiting', () async {
final result = signIn();
await pageOpened.future;
final stray = await browserAsks('/favicon.ico');
final response = await browserAsks('/oidc-callback?code=c&state=s');
expect(stray.status, HttpStatus.notFound);
expect(response.status, HttpStatus.ok);
expect(await result, '$callback?code=c&state=s');
});
test('listens on the loopback address only', () async {
final otherAddresses = [
for (final interface in await NetworkInterface.list())
for (final address in interface.addresses)
if (!address.isLoopback) address,
];
final result = signIn();
await pageOpened.future;
for (final address in otherAddresses) {
await expectLater(
browserAsks('/oidc-callback?code=c&state=s', address: address),
throwsA(isA<SocketException>()),
reason: '$address',
);
}
await browserAsks('/oidc-callback?code=c&state=s');
expect(await result, '$callback?code=c&state=s');
});
test("stops listening when the browser can't be opened", () async {
webAuth = loopback(opens: false);
await expectLater(signIn(), throwsA(isA<PlatformException>()));
await expectLater(
browserAsks('/oidc-callback?code=c&state=s'),
throwsA(isA<SocketException>()),
);
});
test("signs in even when the window can't come to the front", () async {
webAuth = loopback(
bringToFront: () => Future.error(MissingPluginException()),
);
final result = signIn();
await pageOpened.future;
await browserAsks('/oidc-callback?code=c&state=s');
expect(await result, '$callback?code=c&state=s');
});
test(
'refuses a callback other than a path on 127.0.0.1 with a port',
() async {
for (final address in [
'http://0.0.0.0:$port/oidc-callback',
'http://localhost:$port/oidc-callback',
'http://example.com:$port/oidc-callback',
'https://127.0.0.1:$port/oidc-callback',
'http://127.0.0.1/oidc-callback',
'http://127.0.0.1:$port',
]) {
await expectLater(
webAuth.authenticate(
url: providerPage,
callbackUrlScheme: address,
options: const {},
),
throwsArgumentError,
reason: address,
);
}
expect(pageOpened.isCompleted, isFalse);
},
);
}
/// How flutter_web_auth_2 reports a cancelled sign-in, which is how the
/// browser half's callers learn of it.
final _cancelled = isA<PlatformException>().having(
(error) => error.code,
'code',
'CANCELED',
);
/// The embedder reporting the app's lifecycle, as it does when the window
/// gains or loses focus.
Future<void> _embedderReports(AppLifecycleState state) =>
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.handlePlatformMessage(
SystemChannels.lifecycle.name,
SystemChannels.lifecycle.codec.encodeMessage(state.toString()),
(_) {},
);

View file

@ -0,0 +1,184 @@
import 'dart:async';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_web_auth_2/flutter_web_auth_2.dart';
import 'package:halkyon_oidc_flutter/halkyon_oidc_flutter.dart';
void main() {
const state = 'app-scheme.s1';
final start = OidcStart(
attempt: UuidValue.fromString('5b9d7f2e-0c1a-4e3b-9f6d-2a8c4e1b7d30'),
authorizationUrl: Uri.parse(
'https://id.example.org/authorize?client_id=app&state=$state',
),
state: state,
);
late List<({String url, String callback, FlutterWebAuth2Options options})>
opened;
late FutureOr<String> Function() comesBack;
late _FakeLoopback loopback;
late OidcBrowser browser;
setUp(() {
opened = [];
comesBack = () => 'my.app://oidc-callback?code=c&state=$state';
loopback = _FakeLoopback();
browser = OidcBrowser(
loopback: loopback,
authenticate:
({
required url,
required callbackUrlScheme,
options = const FlutterWebAuth2Options(),
}) async {
opened.add((
url: url,
callback: callbackUrlScheme,
options: options,
));
return await comesBack();
},
);
});
group('by the scheme', () {
test("opens the provider's page and returns the code", () async {
final back = await browser.open(start, const OidcReturn.scheme('my.app'));
expect(back, const OidcCode('c'));
expect(opened.single.url, start.authorizationUrl.toString());
expect(opened.single.callback, 'my.app');
expect(loopback.waits, isEmpty);
});
test("returns the provider's refusal", () async {
comesBack = () => 'my.app://oidc-callback?error=deny&state=$state';
expect(
await browser.open(start, const OidcReturn.scheme('my.app')),
const OidcDenied('deny'),
);
});
test('returns nothing when the person closed the browser', () async {
comesBack = () => throw PlatformException(code: 'CANCELED');
expect(
await browser.open(start, const OidcReturn.scheme('my.app')),
isNull,
);
});
});
test('by https, names the host and the path the system catches', () async {
comesBack = () => 'https://example.org/oidc-callback?code=c&state=$state';
final back = await browser.open(
start,
const OidcReturn.https(host: 'example.org', path: '/oidc-callback'),
);
expect(back, const OidcCode('c'));
expect(opened.single.callback, 'https');
expect(opened.single.options.httpsHost, 'example.org');
expect(opened.single.options.httpsPath, '/oidc-callback');
});
group('by the loopback', () {
const back = OidcReturn.loopback(port: 43823, path: '/oidc-callback');
test('waits on the loopback address, not through the package', () async {
loopback.comesBack = () =>
'http://127.0.0.1:43823/oidc-callback?code=c&state=$state';
expect(await browser.open(start, back), const OidcCode('c'));
expect(loopback.waits.single, (
url: start.authorizationUrl.toString(),
callback: 'http://127.0.0.1:43823/oidc-callback',
));
expect(opened, isEmpty);
});
test('returns nothing once the wait is cancelled or runs out', () async {
for (final error in [
PlatformException(code: 'CANCELED'),
TimeoutException('5 minutes'),
]) {
loopback.comesBack = () => throw error;
expect(await browser.open(start, back), isNull, reason: '$error');
}
});
test('cancel stops the wait', () async {
await browser.cancel();
expect(loopback.cancelled, 1);
});
});
test('refuses what came back for another sign-in', () async {
comesBack = () => 'my.app://oidc-callback?code=c&state=app-scheme.other';
await expectLater(
browser.open(start, const OidcReturn.scheme('my.app')),
throwsA(_problem(OidcCallbackProblem.wrongState)),
);
});
test("says when there's no browser to open the page in", () async {
for (final code in ['NO_BROWSER', 'SECURITY_EXCEPTION']) {
comesBack = () => throw PlatformException(code: code);
await expectLater(
browser.open(start, const OidcReturn.scheme('my.app')),
throwsA(_problem(OidcCallbackProblem.noBrowser)),
reason: code,
);
}
});
test('says when the way back failed otherwise', () async {
for (final code in ['FAILED', 'CANNOT_RESTORE', 'UNKNOWN']) {
comesBack = () => throw PlatformException(code: code);
await expectLater(
browser.open(start, const OidcReturn.scheme('my.app')),
throwsA(_problem(OidcCallbackProblem.failed)),
reason: code,
);
}
});
}
/// The loopback with no port and no browser: records what it is asked to
/// wait for and answers with [comesBack].
class _FakeLoopback() implements LoopbackWebAuth {
final waits = <({String url, String callback})>[];
FutureOr<String> Function() comesBack = () => throw UnimplementedError();
int cancelled = 0;
@override
String get appName => 'My App';
@override
Future<String> authenticate({
required String url,
required String callbackUrlScheme,
required Map<String, dynamic> options,
}) async {
waits.add((url: url, callback: callbackUrlScheme));
return await comesBack();
}
@override
Future<void> cancel() async => cancelled++;
@override
Future<void> clearAllDanglingCalls() async {}
}
Matcher _problem(OidcCallbackProblem problem) => isA<OidcCallbackException>()
.having((error) => error.problem, 'problem', problem);

Some files were not shown because too many files have changed in this diff Show more