garage_auth, garage_entitlements, garage_iap and garage_ui, moved out of Garage-Services and Metro-Map-Maker into one public repo. MIT, one readme, docs under docs/. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013F4NWNvYcdeSgqbWMT1VQ7
474 lines
17 KiB
Markdown
474 lines
17 KiB
Markdown
# Platform setup
|
|
|
|
The bits of wiring that live in your app's runner folders, entitlements and
|
|
manifests rather than in Dart. None of it is something a package can do for
|
|
you, which is why it's all in one place here.
|
|
|
|
- [Redirect URIs](#redirect-uris)
|
|
- [Catching the callback on native](#catching-the-callback-on-native)
|
|
- [Token storage](#token-storage)
|
|
- [macOS network access](#macos-network-access)
|
|
- [garage_iap and Stripe](#garage_iap-and-stripe)
|
|
- [garage_ui's macOS plugin](#garage_uis-macos-plugin)
|
|
- [The pub bug with git + path deps](#the-pub-bug-with-git--path-deps)
|
|
|
|
|
|
## Redirect URIs
|
|
|
|
### Registering the client
|
|
|
|
Your app is a **public** PKCE client. `garage_auth` never sends a
|
|
`client_secret` — it cant, anything shipped in an app binary isnt a secret.
|
|
Create the client under your project in the hub (or over the API with
|
|
`"is_public": true`) and list every redirect URI the app will ever send.
|
|
|
|
What Garage lets you register:
|
|
|
|
| Shape | Example | For |
|
|
| --- | --- | --- |
|
|
| `https://` | `https://fieldnotes.example/auth/callback` | web |
|
|
| `http://` on loopback only | `http://localhost:8765/auth/callback` | local dev |
|
|
| custom scheme | `fieldnotes://auth/callback` | native + desktop |
|
|
|
|
The `redirect_uri` sent to `/authorize` has to match a registered one
|
|
character for character. There are no wildcards. The one thing that floats is
|
|
the **port on a loopback URL** — register `http://localhost:8765/auth/callback`
|
|
and `flutter run -d chrome` on whatever random port it picks will still match.
|
|
Scheme, host and path still have to be exact.
|
|
|
|
`garage_auth` sends the same resolved URI to `/authorize` and again in the
|
|
token exchange, so if one works the other will too.
|
|
|
|
### Web ignores your scheme and host
|
|
|
|
On web, `redirect_web.dart` throws away the scheme and host of the
|
|
`redirectUri` you passed and uses `window.location.origin` instead. Only the
|
|
**path** is kept. So with
|
|
|
|
```dart
|
|
GarageAuth(redirectUri: "https://fieldnotes.example/auth/callback", ...)
|
|
```
|
|
|
|
a build served from `https://beta.fieldnotes.example` sends
|
|
`https://beta.fieldnotes.example/auth/callback`. That's deliberate, the IdP
|
|
bounces back to the same deployment the user started on. The catch is that
|
|
**every origin you deploy to needs its own registered redirect URI** — prod,
|
|
staging, a preview domain, all of them. Localhost is covered by the loopback
|
|
port rule above.
|
|
|
|
How the path gets picked:
|
|
|
|
- A schemeless value (`/auth/callback`) or an `http(s)` URL — its path is used.
|
|
- A custom scheme URI — falls back to `/auth/callback`.
|
|
|
|
That fallback exists because of a real gotcha. In `garagepay://auth/callback`
|
|
the `auth` bit is the **host**, not part of the path, and the path is just
|
|
`/callback`. Taking the path off it used to produce
|
|
`https://host/callback`, which nobody had registered, and sign-in died with
|
|
`redirect_uri not registered`. So a custom-scheme URI on web always means
|
|
`/auth/callback`, whatever you wrote after the scheme.
|
|
|
|
If you want web to land somewhere other than `/auth/callback`, pass a web
|
|
shaped value when you're on web:
|
|
|
|
```dart
|
|
final auth = GarageAuth(
|
|
issuer: "https://hub.imbenji.net/auth-api",
|
|
clientId: "field-notes",
|
|
redirectUri: kIsWeb ? "/signed-in" : "fieldnotes://auth/callback",
|
|
);
|
|
```
|
|
|
|
and make sure that path is both a route in your app and registered on the
|
|
client, per origin.
|
|
|
|
### Native uses it verbatim
|
|
|
|
On iOS, Android, macOS, Windows and Linux (`redirect_io.dart`) the configured
|
|
URI is used exactly as given. `signIn()` opens the authorize URL in the
|
|
**external** browser via `url_launcher` (`LaunchMode.externalApplication`) and
|
|
returns straight away. Garage redirects the browser to your custom scheme, the
|
|
OS hands that to your app, and your app has to catch it and call
|
|
`completeSignIn(uri.queryParameters)`. Nothing in `garage_auth` listens for
|
|
the link itself.
|
|
|
|
|
|
## Catching the callback on native
|
|
|
|
Two jobs: tell the OS your app owns the scheme, and listen for the link in
|
|
Dart. [`app_links`](https://pub.dev/packages/app_links) does the listening on
|
|
every platform and its per-platform docs are the reference for the runner
|
|
changes — the snippets below are from those docs, check them against the
|
|
version you actually resolve.
|
|
|
|
The Dart side:
|
|
|
|
```dart
|
|
final appLinks = AppLinks(); // singleton, make it early so the cold-start link isnt missed
|
|
|
|
appLinks.uriLinkStream.listen((uri) async {
|
|
if (uri.scheme != "fieldnotes") return;
|
|
try {
|
|
await auth.completeSignIn(uri.queryParameters);
|
|
} catch (e, st) {
|
|
print("sign in callback failed: $e\n$st");
|
|
}
|
|
});
|
|
```
|
|
|
|
`uriLinkStream` delivers the initial link as well as later ones.
|
|
|
|
From Flutter 3.24 Flutter's own deep link handling has to be switched off or it
|
|
fights `app_links` for the link. That's the `FlutterDeepLinkingEnabled` /
|
|
`flutter_deeplinking_enabled` lines below.
|
|
|
|
### iOS
|
|
|
|
`ios/Runner/Info.plist`, inside the top `<dict>`:
|
|
|
|
```xml
|
|
<key>CFBundleURLTypes</key>
|
|
<array>
|
|
<dict>
|
|
<key>CFBundleURLName</key>
|
|
<string>fieldnotes</string>
|
|
<key>CFBundleURLSchemes</key>
|
|
<array>
|
|
<string>fieldnotes</string>
|
|
</array>
|
|
</dict>
|
|
</array>
|
|
|
|
<key>FlutterDeepLinkingEnabled</key>
|
|
<false/>
|
|
```
|
|
|
|
`app_links` 7 on iOS needs Flutter 3.38.1 or newer, and supports both the
|
|
app-delegate and the newer scene lifecycle.
|
|
|
|
### macOS
|
|
|
|
Same `CFBundleURLTypes` block, in `macos/Runner/Info.plist`:
|
|
|
|
```xml
|
|
<key>CFBundleURLTypes</key>
|
|
<array>
|
|
<dict>
|
|
<key>CFBundleURLName</key>
|
|
<string>fieldnotes</string>
|
|
<key>CFBundleURLSchemes</key>
|
|
<array>
|
|
<string>fieldnotes</string>
|
|
</array>
|
|
</dict>
|
|
</array>
|
|
```
|
|
|
|
### Android
|
|
|
|
`android/app/src/main/AndroidManifest.xml`, inside the `<activity>` for
|
|
`.MainActivity`:
|
|
|
|
```xml
|
|
<meta-data android:name="flutter_deeplinking_enabled" android:value="false" />
|
|
|
|
<intent-filter>
|
|
<action android:name="android.intent.action.VIEW" />
|
|
<category android:name="android.intent.category.DEFAULT" />
|
|
<category android:name="android.intent.category.BROWSABLE" />
|
|
<data android:scheme="fieldnotes" android:host="auth" />
|
|
</intent-filter>
|
|
```
|
|
|
|
Note the host. For `fieldnotes://auth/callback` the host is `auth` (same
|
|
host-vs-path thing as the web gotcha above). You can drop `android:host` and
|
|
match on the scheme alone, but keeping it cuts down on clashing with another
|
|
app that picked the same scheme.
|
|
|
|
Test it without going through sign-in:
|
|
|
|
```sh
|
|
adb shell am start -a android.intent.action.VIEW \
|
|
-d "fieldnotes://auth/callback?code=x\&state=y"
|
|
```
|
|
|
|
(`completeSignIn` will throw a state mismatch on that, which is fine, it proves
|
|
the link arrived.)
|
|
|
|
### Windows
|
|
|
|
Windows doesnt read a manifest for a plain win32 app, the scheme has to go in
|
|
the registry, and `app_links` wont do that for you. Two routes, both in the
|
|
`app_links` Windows doc:
|
|
|
|
- **Packaged with [`msix`](https://pub.dev/packages/msix)** — add
|
|
`protocol_activation: fieldnotes` under `msix_config` and the installer
|
|
registers (and on uninstall, removes) it. Only works for the packaged app,
|
|
not while debugging.
|
|
- **Unpackaged** — write `HKCU\Software\Classes\fieldnotes` yourself, with an
|
|
empty `URL Protocol` value and `shell\open\command` set to
|
|
`"<path to exe>" "%1"`. The doc has a `win32_registry` snippet for it.
|
|
|
|
You also want the link going to the instance thats allready running (the one
|
|
waiting on sign-in), not a fresh one. In `windows/runner/main.cpp`:
|
|
|
|
```cpp
|
|
#include "app_links/app_links_plugin_c_api.h"
|
|
|
|
int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
|
|
_In_ wchar_t *command_line, _In_ int show_command) {
|
|
if (SendAppLinkToInstance()) {
|
|
return EXIT_SUCCESS;
|
|
}
|
|
// ...
|
|
```
|
|
|
|
### Linux
|
|
|
|
Two halves again. The scheme is registered by whatever installs the app — a
|
|
`.desktop` entry with `x-scheme-handler/fieldnotes` in its mime types (if you
|
|
build packages with `flutter_distributor`, that's `supported_mime_type` in
|
|
`make_config.yaml`). And `linux/my_application.cc` has to become a single
|
|
instance app that accepts the URL on the command line — the `app_links` Linux
|
|
doc has the exact patch (present the existing window in `activate`, return
|
|
`FALSE` from `local_command_line`, and swap `G_APPLICATION_NON_UNIQUE` for
|
|
`G_APPLICATION_HANDLES_COMMAND_LINE | G_APPLICATION_HANDLES_OPEN`). Copy it
|
|
from there rather than from here, it touches three spots in the file.
|
|
|
|
|
|
## Token storage
|
|
|
|
The default `SecureTokenStore` is `flutter_secure_storage`. Worth knowing
|
|
before you debug anything: the store doesnt only hold tokens. `beginSignIn()`
|
|
writes the PKCE verifier and `state` into it, and `completeSignIn()` reads them
|
|
back. So a store that cant write, or one that forgets across the round trip,
|
|
breaks **sign-in**, not just "stay signed in". On web the tab reloads at the
|
|
callback, so a `MemoryTokenStore` there gives you a `State mismatch on OAuth
|
|
callback` every time.
|
|
|
|
### macOS: unsigned builds cant use the Keychain
|
|
|
|
On macOS `flutter_secure_storage` wants the `keychain-access-groups`
|
|
entitlement, in both `DebugProfile.entitlements` and `Release.entitlements`.
|
|
A useful value for it involves `$(AppIdentifierPrefix)` — your team ID — which
|
|
only exists when you sign with a real development certificate.
|
|
|
|
Ad-hoc signed (`CODE_SIGN_IDENTITY = "-"`, what you get with no team set), it
|
|
goes wrong either way:
|
|
|
|
- **with** the entitlement, the build fails, there's no team for it to resolve
|
|
against;
|
|
- **without** it, every Keychain call returns `-34018`
|
|
(`errSecMissingEntitlement`). Sign-in fails at the verifier stash and
|
|
`restore()` finds nothing.
|
|
|
|
Fixes, pick one:
|
|
|
|
1. Sign the app properly (a team, a development cert) and add the entitlement.
|
|
2. Pass your own `TokenStore` until you do:
|
|
|
|
```dart
|
|
final auth = GarageAuth(
|
|
issuer: ...,
|
|
clientId: ...,
|
|
redirectUri: ...,
|
|
tokenStore: MyPrefsTokenStore(), // anything that implements read/write/delete
|
|
);
|
|
```
|
|
|
|
`flutter_secure_storage`'s own README also documents a third way on 10 and
|
|
newer: `MacOsOptions(usesDataProtectionKeychain: false)` drops to the legacy
|
|
Keychain, which doesnt need the entitlement. `SecureTokenStore` takes a
|
|
`storage:` argument so you could hand it one configured like that. We havent
|
|
leaned on it ourselves, so treat that as their claim, not ours.
|
|
|
|
Their README has one more macOS catch: Keychain Sharing needs a provisioning
|
|
profile, and on a free Apple developer account Xcode embeds a machine specific
|
|
one, so the built app only launches on the Mac that built it.
|
|
|
|
### Web compiled to wasm
|
|
|
|
`flutter build web --wasm` fails if the graph resolves `flutter_secure_storage`
|
|
**9**. Its web half (`flutter_secure_storage_web` 1.x) is written against
|
|
`dart:html`, which dart2wasm doesnt have. And a custom `TokenStore` doesnt save
|
|
you — the generated web plugin registrant imports every web plugin in the
|
|
dependency graph whether your code touches it or not, so it gets compiled
|
|
anyway.
|
|
|
|
The packages allow `flutter_secure_storage: ">=9.2.2 <12.0.0"`. Make sure you
|
|
resolve onto **10 or 11**, whose web half (`flutter_secure_storage_web` 2.x)
|
|
doesnt use `dart:html`. If something else in your app is holding it on 9, pin
|
|
it:
|
|
|
|
```yaml
|
|
dependencies:
|
|
flutter_secure_storage: ^10.0.0 # or ^11.0.0
|
|
```
|
|
|
|
Knock-on effect on Android: `flutter_secure_storage` 10 raised its minimum to
|
|
**API 23**. If your `minSdk` is lower, bump it.
|
|
|
|
On web the storage is best effort either way — the README calls its WebCrypto
|
|
backed web implementation experimental.
|
|
|
|
|
|
## macOS network access
|
|
|
|
A macOS app from `flutter create` runs in the App Sandbox, and the sandbox
|
|
blocks outgoing connections untill you add the client entitlement. Every one
|
|
of these packages talks to Garage over HTTP, so without it the first discovery
|
|
fetch fails (usually as a `SocketException: Connection failed (Operation not
|
|
permitted)`).
|
|
|
|
Add it to **both** `macos/Runner/DebugProfile.entitlements` and
|
|
`macos/Runner/Release.entitlements`:
|
|
|
|
```xml
|
|
<key>com.apple.security.network.client</key>
|
|
<true/>
|
|
```
|
|
|
|
The debug profile file allready has `network.server` in it by default. That's
|
|
incoming connections, so the flutter tools can talk to the running app — it
|
|
does nothing for your requests. Dont remove it, and dont mistake it for the
|
|
one you need. Check both files, it's easy to have one build working and the
|
|
other not.
|
|
|
|
|
|
## garage_iap and Stripe
|
|
|
|
`PurchaseMode.sheet` uses `flutter_stripe`'s PaymentSheet. `garage_iap` pins
|
|
`flutter_stripe: ^11.1.0`, so these are the 11.x requirements from its README.
|
|
|
|
### Where the sheet is even tried
|
|
|
|
`sheet.dart` conditionally imports `sheet_io.dart` when `dart:io` exists and
|
|
`sheet_stub.dart` otherwise:
|
|
|
|
- **iOS, Android** — the real sheet.
|
|
- **macOS, Windows, Linux** — `sheet_io.dart` checks `Platform.isIOS ||
|
|
Platform.isAndroid` and returns `unsupported`.
|
|
- **Web** — the stub, always `unsupported`.
|
|
|
|
`unsupported` drops to the browser handoff. So does any **subscription**,
|
|
because the server answers the payment-intent call with `fallback: "handoff"`.
|
|
|
|
### The setup is not optional on iOS / Android
|
|
|
|
Heads up, this one catches people out: the handoff fallback only covers
|
|
platforms with **no** sheet. On iOS and Android the sheet is always attempted,
|
|
and if the platform setup below is missing, `initPaymentSheet` /
|
|
`presentPaymentSheet` fail. `sheet_io.dart` only swallows a
|
|
`FailureCode.Canceled` — anything else is logged and rethrown, and `purchase()`
|
|
throws. It doesnt fall back. If you cant do the setup, call
|
|
`purchase(..., mode: PurchaseMode.handoff)` explicitly.
|
|
|
|
**iOS** — iOS 13 or above. In `ios/Podfile`:
|
|
|
|
```ruby
|
|
platform :ios, '13.0'
|
|
```
|
|
|
|
and match `IPHONEOS_DEPLOYMENT_TARGET` in the Xcode build settings. If you
|
|
want card scanning, add an `NSCameraUsageDescription` to `Info.plist`.
|
|
|
|
**Android** — the Stripe Android SDK uses AppCompat UI and the support fragment
|
|
manager for the sheet, so:
|
|
|
|
1. `MainActivity.kt` extends `FlutterFragmentActivity`, not `FlutterActivity`:
|
|
|
|
```kotlin
|
|
import io.flutter.embedding.android.FlutterFragmentActivity
|
|
|
|
class MainActivity : FlutterFragmentActivity()
|
|
```
|
|
|
|
2. The activity theme is a descendant of `Theme.AppCompat`. In
|
|
`android/app/src/main/res/values/styles.xml`, `LaunchTheme` becomes
|
|
`parent="Theme.AppCompat.Light.NoActionBar"` and in `values-night/styles.xml`
|
|
`parent="Theme.AppCompat.DayNight.NoActionBar"`. Stripe's example app sets
|
|
`NormalTheme` to `parent="Theme.MaterialComponents"`.
|
|
|
|
3. minSdk 21+ (but see the API 23 note above), Kotlin 1.8.0+, Android Gradle
|
|
plugin 8+.
|
|
|
|
4. Their ProGuard `-dontwarn` rules in `proguard-rules.pro`, and for 11.x
|
|
`android.enableR8.fullMode=false` in `gradle.properties`. Copy both from the
|
|
README of the exact version you resolved, the rule list has changed between
|
|
versions.
|
|
|
|
None of this hot reloads. Do a full rebuild after.
|
|
|
|
The handoff itself only needs `url_launcher`, which works everywhere without
|
|
setup (on macOS it still needs the network entitlement above, like
|
|
everything else).
|
|
|
|
|
|
## garage_ui's macOS plugin
|
|
|
|
`garage_ui` declares one native plugin, macOS only:
|
|
|
|
```yaml
|
|
flutter:
|
|
plugin:
|
|
platforms:
|
|
macos:
|
|
pluginClass: GarageUiPlugin
|
|
```
|
|
|
|
`GarageUiPlugin` registers two method channels:
|
|
|
|
- **`garage/eyedropper`** — `isAvailable` and `pick`, backed by
|
|
`NSColorSampler` (the system loupe, whole screen).
|
|
- **`garage/cursor_lock`** — `lock` / `unlock`, hides and pins the cursor while
|
|
you scrub a number field, and pushes raw `scrubDelta` calls back to Dart
|
|
while locked.
|
|
|
|
It's picked up by the generated plugin registrant, so theres nothing to add to
|
|
`MainFlutterWindow.swift`. The podspec targets macOS 10.15.
|
|
|
|
Everywhere else there's no native side, and the Dart side copes:
|
|
|
|
- **Eyedropper** — `eyedropper.dart` imports the web version when
|
|
`dart.library.html` exists (JS web builds) and uses the browser `EyeDropper`
|
|
API, which is Chromium only, feature checked. Everything else uses the native
|
|
version, which answers `false` for "available" off macOS without touching the
|
|
channel. Callers then fall back to sampling the app's own frame, which is
|
|
window only but works everywhere. A wasm build has no `dart.library.html`,
|
|
so it takes the native version: unavailable unless the browser reports
|
|
macOS, in which case the channel call fails, gets logged, and it's
|
|
unavailable anyway.
|
|
- **Cursor lock** — `CursorLock.isSupported` is `!kIsWeb && macOS`. Off macOS
|
|
`lock()` / `unlock()` do nothing and no deltas come back, so the scrub widget
|
|
drives itself off Flutter's normal pointer events.
|
|
|
|
If you see a `MissingPluginException` for either channel on macOS, the runner
|
|
hasnt been rebuilt since `garage_ui` was added — a hot restart doesnt register
|
|
new plugins, a full `flutter run` / build does.
|
|
|
|
|
|
## The pub bug with git + path deps
|
|
|
|
On some Dart versions pub loses the dependencies of a **git package that has
|
|
a path dependency of its own**. That's exactly our shape: `garage_entitlements`
|
|
and `garage_iap` are git deps (with `path:`) that themselves depend on
|
|
`../garage_auth` by path. When it hits, the extra deps those packages declare —
|
|
`pointycastle` is the one you'll notice — go missing from the resolved graph,
|
|
and the build dies with an error mentioning `package_graph.json` (something
|
|
like `dependencies for ... missing. Try running flutter pub get`, which doesnt
|
|
help).
|
|
|
|
We've seen it on Dart 3.12.0; 3.12.2 is fine. Upgrading is the real fix. If you
|
|
cant, declare the missing dep directly in your app so pub resolves it
|
|
regardless:
|
|
|
|
```yaml
|
|
dependencies:
|
|
pointycastle: ^4.0.0
|
|
```
|
|
|
|
Upstream: [dart-lang/pub#2447](https://github.com/dart-lang/pub/issues/2447)
|
|
(git package depending on a path package) and
|
|
[dart-lang/pub#4674](https://github.com/dart-lang/pub/issues/4674) (the
|
|
`package_graph.json` error).
|