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:
parent
7ea9f16904
commit
3cb2000927
22 changed files with 706 additions and 42 deletions
|
|
@ -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_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_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).
|
||||
- **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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
# 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
|
||||
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
|
||||
with SimpleLogin only).
|
||||
|
||||
|
|
|
|||
|
|
@ -59,7 +59,8 @@ Written as the apps are: Dart 3.13's primary constructors, Material from
|
|||
fails a sign-out.
|
||||
- **Signing in** (`lib/src/auth/`): `AuthRepository` on an `AuthGateway`;
|
||||
`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
|
||||
in place (`useSession`); `SessionBloc`, `GitHubSignInBloc` and
|
||||
`SignInWaysCubit`; `SignedOutMark`, `SignInAddresses`, `PersonFolders`.
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@ export 'src/auth/bloc/session_bloc.dart';
|
|||
export 'src/auth/bloc/sign_in_ways_cubit.dart';
|
||||
export 'src/auth/data/auth_gateway.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/sign_in_addresses.dart';
|
||||
export 'src/auth/data/signed_out_mark.dart';
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import 'dart:io';
|
|||
|
||||
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/loopback_web_auth.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_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'
|
||||
show GitHubSignInRefusal, GitHubSignInRefusedException;
|
||||
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:serverpod_auth_idp_flutter/serverpod_auth_idp_flutter.dart';
|
||||
|
||||
|
|
|
|||
|
|
@ -29,8 +29,9 @@ dependencies:
|
|||
# The account's private keys, once unlocked, in the system's keyring
|
||||
# (ADR 0017), as Serverpod keeps sessions.
|
||||
flutter_secure_storage: ^10.0.0
|
||||
# LoopbackWebAuth takes the place of the package's own browser half on
|
||||
# Linux and Windows.
|
||||
# GitHub's sign-in goes through the package's front, so LoopbackWebAuth
|
||||
# (halkyon_oidc_flutter) takes the place of its browser half on Linux and
|
||||
# Windows.
|
||||
flutter_web_auth_2_platform_interface: ^5.0.0
|
||||
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.
|
||||
halkyon_keys:
|
||||
path: ../halkyon_keys
|
||||
halkyon_oidc_flutter:
|
||||
path: ../halkyon_oidc_flutter
|
||||
halkyon_ui:
|
||||
path: ../halkyon_ui
|
||||
# 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.
|
||||
sodium: 4.1.0+1
|
||||
url_launcher: ^6.3.2
|
||||
window_to_front: ^0.0.4
|
||||
|
||||
dev_dependencies:
|
||||
fake_async: ^1.3.3
|
||||
|
|
|
|||
31
packages/halkyon_oidc_flutter/.gitignore
vendored
Normal file
31
packages/halkyon_oidc_flutter/.gitignore
vendored
Normal 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/
|
||||
5
packages/halkyon_oidc_flutter/CHANGELOG.md
Normal file
5
packages/halkyon_oidc_flutter/CHANGELOG.md
Normal 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.
|
||||
28
packages/halkyon_oidc_flutter/LICENSE
Normal file
28
packages/halkyon_oidc_flutter/LICENSE
Normal 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.
|
||||
75
packages/halkyon_oidc_flutter/README.md
Normal file
75
packages/halkyon_oidc_flutter/README.md
Normal 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).
|
||||
9
packages/halkyon_oidc_flutter/analysis_options.yaml
Normal file
9
packages/halkyon_oidc_flutter/analysis_options.yaml
Normal 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
|
||||
12
packages/halkyon_oidc_flutter/lib/halkyon_oidc_flutter.dart
Normal file
12
packages/halkyon_oidc_flutter/lib/halkyon_oidc_flutter.dart
Normal 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';
|
||||
151
packages/halkyon_oidc_flutter/lib/src/loopback_web_auth.dart
Normal file
151
packages/halkyon_oidc_flutter/lib/src/loopback_web_auth.dart
Normal 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>
|
||||
''';
|
||||
}
|
||||
76
packages/halkyon_oidc_flutter/lib/src/oidc_browser.dart
Normal file
76
packages/halkyon_oidc_flutter/lib/src/oidc_browser.dart
Normal 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();
|
||||
}
|
||||
78
packages/halkyon_oidc_flutter/lib/src/oidc_callback.dart
Normal file
78
packages/halkyon_oidc_flutter/lib/src/oidc_callback.dart
Normal 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})';
|
||||
}
|
||||
38
packages/halkyon_oidc_flutter/lib/src/oidc_return.dart
Normal file
38
packages/halkyon_oidc_flutter/lib/src/oidc_return.dart
Normal 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;
|
||||
36
packages/halkyon_oidc_flutter/pubspec.yaml
Normal file
36
packages/halkyon_oidc_flutter/pubspec.yaml
Normal 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
|
||||
259
packages/halkyon_oidc_flutter/test/loopback_web_auth_test.dart
Normal file
259
packages/halkyon_oidc_flutter/test/loopback_web_auth_test.dart
Normal 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()),
|
||||
(_) {},
|
||||
);
|
||||
184
packages/halkyon_oidc_flutter/test/oidc_browser_test.dart
Normal file
184
packages/halkyon_oidc_flutter/test/oidc_browser_test.dart
Normal 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
Loading…
Reference in a new issue