The Garage SDKs, in the open

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
This commit is contained in:
ImBenji
2026-09-23 18:49:21 +01:00
co-authored by Claude Opus 5.5
commit b269201919
117 changed files with 26944 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 IMBENJI.NET LTD
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+6
View File
@@ -0,0 +1,6 @@
include: package:flutter_lints/flutter.yaml
linter:
rules:
# the SDK prints caught errors to console on purpose for debugging
avoid_print: false
+60
View File
@@ -0,0 +1,60 @@
// Minimal, runnable-shaped example of wiring GarageAuth into a Flutter app.
// Not a full app — just the moving parts. See the README for the callback
// route + deep-link handling.
import "package:flutter/material.dart";
import "package:garage_auth/garage_auth.dart";
final auth = GarageAuth(
issuer: "https://hub.imbenji.net/auth-api",
clientId: "my-app",
redirectUri: "myapp://auth/callback",
);
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await auth.restore();
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: ListenableBuilder(
listenable: auth,
builder: (context, _) {
return Scaffold(
appBar: AppBar(title: const Text("garage_auth example")),
body: Center(
child: auth.isSignedIn
? Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text("Signed in 🎉"),
TextButton(
onPressed: () async {
final me = await auth.profile();
debugPrint("profile: $me");
},
child: const Text("Print profile"),
),
TextButton(
onPressed: auth.signOut,
child: const Text("Sign out"),
),
],
)
: TextButton(
onPressed: auth.signIn,
child: const Text("Sign in with Garage"),
),
),
);
},
),
);
}
}
+12
View File
@@ -0,0 +1,12 @@
// Sign in with Garage — OIDC PKCE auth core for Garage apps.
//
// The public surface is GarageAuth plus the TokenStore seam and AuthError.
// garage_iap (and any future package) builds on this — share one GarageAuth
// instance so everything reuses the same session + authed client.
library;
export "src/garage_auth.dart" show GarageAuth;
export "src/oidc.dart" show AuthError;
export "src/token_store.dart"
show TokenStore, SecureTokenStore, MemoryTokenStore;
export "src/authed_client.dart" show AuthedClient;
+74
View File
@@ -0,0 +1,74 @@
import "dart:async";
import "package:http/http.dart" as http;
// an http.Client that quietly attaches the current bearer token to every
// request, and if a call comes back 401 it tries to refresh the token *once*
// and replays the same request. callers use it exactly like a normal
// http.Client (.get/.post/.send) — auth is invisible to them.
class AuthedClient extends http.BaseClient {
AuthedClient({
required http.Client inner,
required String? Function() tokenSource,
required Future<bool> Function() refresh,
}) : _inner = inner,
_token = tokenSource,
_refresh = refresh;
final http.Client _inner;
final String? Function() _token;
final Future<bool> Function() _refresh;
// we serialise refreshes so a burst of 401s doesnt fire five refreshes at
// once — the first one wins and the rest await it.
Future<bool>? _inflight;
@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
final first = await _inner.send(_withAuth(request, _token()));
if (first.statusCode != 401) return first;
// a streamed body can only be read once, so if the request carried one we
// cant safely replay it. bail out with the 401 in that case.
if (request is! http.Request) return first;
// drain the failed response so the connection can be reused
await first.stream.drain<void>();
final ok = await _refreshOnce();
if (!ok) return first;
return _inner.send(_clone(request, _token()));
}
Future<bool> _refreshOnce() {
_inflight ??= _refresh().whenComplete(() => _inflight = null);
return _inflight!;
}
http.BaseRequest _withAuth(http.BaseRequest req, String? token) {
if (token != null && token.isNotEmpty) {
req.headers["Authorization"] = "Bearer $token";
}
return req;
}
// rebuild a fresh Request because BaseRequest is single-shot once sent
http.Request _clone(http.Request src, String? token) {
final out = http.Request(src.method, src.url)
..headers.addAll(src.headers)
..followRedirects = src.followRedirects
..maxRedirects = src.maxRedirects
..persistentConnection = src.persistentConnection
..bodyBytes = src.bodyBytes;
if (token != null && token.isNotEmpty) {
out.headers["Authorization"] = "Bearer $token";
}
return out;
}
@override
void close() => _inner.close();
}
+305
View File
@@ -0,0 +1,305 @@
import "dart:async";
import "dart:convert";
import "package:flutter/foundation.dart";
import "package:http/http.dart" as http;
import "authed_client.dart";
import "oidc.dart";
import "platform/redirect.dart";
import "token_store.dart";
// storage keys. namespaced by clientId so two GarageAuth instances in the same
// app (different clients) dont clobber each others tokens.
const _kAccess = "ga.access";
const _kRefresh = "ga.refresh";
const _kVerifier = "ga.pkce_verifier";
const _kState = "ga.oauth_state";
// "Sign in with Garage" — the core auth client every Garage app (and the iap
// package) builds on. OIDC Authorization Code + PKCE against the hub, opaque
// token storage + refresh, and an authed http client that replays the bearer.
//
// final auth = GarageAuth(
// issuer: "https://hub.imbenji.net/auth-api",
// clientId: "my-app",
// redirectUri: "myapp://auth/callback",
// );
// await auth.restore(); // pick up an existing session
// await auth.signIn(); // kick off PKCE
// final me = await auth.profile();
// final r = await auth.client.get(Uri.parse(".../v1/whatever"));
//
// it's a ChangeNotifier so UIs can rebuild on sign in / out.
class GarageAuth extends ChangeNotifier {
GarageAuth({
required this.issuer,
required this.clientId,
required this.redirectUri,
this.scopes = const ["openid", "profile", "email"],
http.Client? httpClient,
TokenStore? tokenStore,
PlatformRedirect redirect = const PlatformRedirect(),
}) : _http = httpClient ?? http.Client(),
_store = tokenStore ?? SecureTokenStore(),
_redirect = redirect {
_discovery = OidcDiscovery(issuer, _http);
client = AuthedClient(
inner: _http,
tokenSource: () => _accessToken,
refresh: _refresh,
);
}
final String issuer;
final String clientId;
final String redirectUri;
final List<String> scopes;
final http.Client _http;
final TokenStore _store;
final PlatformRedirect _redirect;
late final OidcDiscovery _discovery;
// the authed http client — adds the bearer, refreshes on 401. share this with
// garage_iap and any other higher level package so they all reuse one session.
late final AuthedClient client;
String? _accessToken;
String? _refreshToken;
bool _restored = false;
String? get accessToken => _accessToken;
bool get isSignedIn => _accessToken != null;
bool get isRestored => _restored;
String _k(String base) => "$base.$clientId";
// -------------------------------------------------------------------------
// session restore — call once on boot
// -------------------------------------------------------------------------
Future<void> restore() async {
try {
_accessToken = await _store.read(_k(_kAccess));
_refreshToken = await _store.read(_k(_kRefresh));
} catch (e) {
print("garage_auth restore failed: $e");
_accessToken = null;
_refreshToken = null;
}
_restored = true;
notifyListeners();
}
// -------------------------------------------------------------------------
// sign in
// -------------------------------------------------------------------------
// begins the PKCE flow. on web this navigates the tab away to the IdP and
// never returns here — the app reloads at the redirect path and must call
// completeSignIn() with the query params. on native it opens the system
// browser; the host app catches the inbound deep link and likewise calls
// completeSignIn(). this is the lower level half of signIn().
Future<void> beginSignIn() async {
if (clientId.isEmpty) {
throw AuthError("GarageAuth: clientId is empty.");
}
final ep = await _discovery.endpoints();
final verifier = randomUrlSafe(64);
final challenge = s256Challenge(verifier);
final state = randomUrlSafe(24);
// stash so completeSignIn can finish the exchange after the round trip
await _store.write(_k(_kVerifier), verifier);
await _store.write(_k(_kState), state);
final resolved = _redirect.resolveRedirectUri(redirectUri);
final authUri = Uri.parse(ep.authorize).replace(queryParameters: {
"response_type": "code",
"client_id": clientId,
"redirect_uri": resolved,
"scope": scopes.join(" "),
"state": state,
"code_challenge": challenge,
"code_challenge_method": "S256",
});
await _redirect.navigateTo(authUri.toString());
}
// convenience over beginSignIn(). on web this triggers the redirect and the
// future never really "completes" (the page is leaving) — your callback route
// drives completeSignIn. on native it's the same begin step; the deep-link
// handler completes it. so think of signIn() as "start the flow".
Future<void> signIn() => beginSignIn();
// finishes the exchange. callers pass the query params off the inbound
// callback url (web: go_router state.uri.queryParameters, native: parse the
// deep link). returns true when a token was obtained.
Future<bool> completeSignIn(Map<String, String> params) async {
final code = params["code"];
final returnedState = params["state"];
final error = params["error"];
if (error != null) {
print("garage_auth callback error: $error");
throw AuthError("Sign in failed: $error");
}
if (code == null || code.isEmpty) {
throw AuthError("No authorization code in callback.");
}
final savedState = await _store.read(_k(_kState));
if (savedState == null || savedState != returnedState) {
throw AuthError("State mismatch on OAuth callback.");
}
final verifier = await _store.read(_k(_kVerifier));
if (verifier == null) {
throw AuthError("Missing PKCE verifier — start the sign in again.");
}
final ep = await _discovery.endpoints();
final resolved = _redirect.resolveRedirectUri(redirectUri);
final resp = await _http.post(
Uri.parse(ep.token),
headers: {"Content-Type": "application/x-www-form-urlencoded"},
body: {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": resolved,
"client_id": clientId,
"code_verifier": verifier,
// NO client_secret — public client
},
);
if (resp.statusCode != 200) {
print(
"garage_auth token exchange failed: ${resp.statusCode} ${resp.body}");
throw AuthError("Token exchange failed (${resp.statusCode}).");
}
await _absorbTokens(resp.body);
// single-use bits — clear so a stale verifier cant be replayed
await _store.delete(_k(_kVerifier));
await _store.delete(_k(_kState));
notifyListeners();
return true;
}
// -------------------------------------------------------------------------
// refresh — used internally by the authed client on a 401
// -------------------------------------------------------------------------
Future<bool> _refresh() async {
final rt = _refreshToken;
if (rt == null || rt.isEmpty) return false;
try {
final ep = await _discovery.endpoints();
final resp = await _http.post(
Uri.parse(ep.token),
headers: {"Content-Type": "application/x-www-form-urlencoded"},
body: {
"grant_type": "refresh_token",
"refresh_token": rt,
"client_id": clientId,
},
);
if (resp.statusCode != 200) {
print("garage_auth refresh failed: ${resp.statusCode}");
// refresh token's no good anymore — drop the session so the UI can
// prompt a fresh sign in rather than spinning on dead tokens.
await _clearTokens();
notifyListeners();
return false;
}
await _absorbTokens(resp.body);
notifyListeners();
return true;
} catch (e) {
print("garage_auth refresh threw: $e");
return false;
}
}
Future<void> _absorbTokens(String body) async {
final j = jsonDecode(body) as Map<String, dynamic>;
final access = j["access_token"] as String?;
if (access == null || access.isEmpty) {
throw AuthError("No access_token in token response.");
}
_accessToken = access;
await _store.write(_k(_kAccess), access);
// some flows rotate the refresh token, some dont return one on refresh —
// only overwrite when we actually got a new one.
final refresh = j["refresh_token"] as String?;
if (refresh != null && refresh.isNotEmpty) {
_refreshToken = refresh;
await _store.write(_k(_kRefresh), refresh);
}
}
// -------------------------------------------------------------------------
// profile / userinfo
// -------------------------------------------------------------------------
// reads the OIDC userinfo claims (sub, email, email_verified, is_developer,
// is_admin, …). goes through the authed client so it refreshes on 401.
// returns null when signed out.
Future<Map<String, dynamic>?> profile() async {
if (!isSignedIn) return null;
final ep = await _discovery.endpoints();
final endpoint = ep.userinfo;
if (endpoint == null) {
throw AuthError("Issuer has no userinfo_endpoint in discovery.");
}
final resp = await client.get(Uri.parse(endpoint));
if (resp.statusCode != 200) {
throw AuthError("userinfo failed (${resp.statusCode}).");
}
return jsonDecode(resp.body) as Map<String, dynamic>;
}
// -------------------------------------------------------------------------
// sign out
// -------------------------------------------------------------------------
Future<void> signOut() async {
await _clearTokens();
notifyListeners();
}
Future<void> _clearTokens() async {
_accessToken = null;
_refreshToken = null;
try {
await _store.delete(_k(_kAccess));
await _store.delete(_k(_kRefresh));
} catch (e) {
print("garage_auth signOut storage clear failed: $e");
}
}
@override
void dispose() {
client.close();
super.dispose();
}
}
+83
View File
@@ -0,0 +1,83 @@
import "dart:convert";
import "dart:math";
import "package:crypto/crypto.dart";
import "package:http/http.dart" as http;
class AuthError implements Exception {
AuthError(this.message);
final String message;
@override
String toString() => message;
}
// the bits of the discovery doc we actually use. userinfo is optional in the
// spec but the hub serves it; we fall back gracefully if it's missing.
class OidcEndpoints {
OidcEndpoints({
required this.authorize,
required this.token,
this.userinfo,
this.endSession,
});
final String authorize;
final String token;
final String? userinfo;
final String? endSession;
}
// fetches + caches /.well-known/openid-configuration under the issuer.
class OidcDiscovery {
OidcDiscovery(this.issuer, this._client);
final String issuer;
final http.Client _client;
OidcEndpoints? _cached;
Future<OidcEndpoints> endpoints() async {
if (_cached != null) return _cached!;
final url = "$issuer/.well-known/openid-configuration";
final resp = await _client.get(Uri.parse(url));
if (resp.statusCode != 200) {
throw AuthError("OIDC discovery failed (${resp.statusCode}) at $url");
}
final j = jsonDecode(resp.body) as Map<String, dynamic>;
final authorize = j["authorization_endpoint"] as String?;
final token = j["token_endpoint"] as String?;
if (authorize == null || token == null) {
throw AuthError("Discovery document missing endpoints.");
}
_cached = OidcEndpoints(
authorize: authorize,
token: token,
userinfo: j["userinfo_endpoint"] as String?,
endSession: j["end_session_endpoint"] as String?,
);
return _cached!;
}
}
// ----- PKCE -----
const _pkceChars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
String randomUrlSafe(int len) {
final rnd = Random.secure();
final sb = StringBuffer();
for (var i = 0; i < len; i++) {
sb.write(_pkceChars[rnd.nextInt(_pkceChars.length)]);
}
return sb.toString();
}
String s256Challenge(String verifier) {
final digest = sha256.convert(utf8.encode(verifier));
return base64Url.encode(digest.bytes).replaceAll("=", "");
}
@@ -0,0 +1 @@
export "redirect_io.dart" if (dart.library.js_interop) "redirect_web.dart";
@@ -0,0 +1,23 @@
import "package:url_launcher/url_launcher.dart";
// native / desktop: the redirect uri is whatever custom scheme the app
// registered (myapp://auth/callback). it has to be wired into the per-platform
// runner manifests by the embedding app — we cant do that from a package. we
// just open the system browser and the host app catches the inbound deep link
// on resume and feeds the params back to completeSignIn().
class PlatformRedirect {
const PlatformRedirect();
// on native the configured redirectUri is authoritative — there's no "origin".
String resolveRedirectUri(String configured) => configured;
Future<void> navigateTo(String url) async {
// fire it at the system browser. we dont await the result of the launch
// because the round trip happens out of process.
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
}
// native has no live "current url" — the deep link arrives separately and the
// host app passes its params in. return null so callers know to use those.
Uri? currentUri() => null;
}
@@ -0,0 +1,40 @@
import "package:web/web.dart" as web;
// web: the redirect uri is the origin we're served from plus the callback path,
// and "navigate to authorize" literally drives the browser tab away. the app
// reloads at /auth/callback with ?code=&state= in the query.
class PlatformRedirect {
const PlatformRedirect();
// on web we ignore the app's configured scheme and use the live origin so the
// IdP bounces back to the same deployment. we keep the *path* the app asked
// for though, so it can route the callback wherever it likes.
String resolveRedirectUri(String configured) {
final loc = web.window.location;
// pull the path off whatever the app configured. if it gave us a custom
// scheme (a native deep link) fall back to /auth/callback.
//
// careful with the deep link shape: in "garagepay://auth/callback" the
// "auth" is the HOST and the path is only "/callback", so taking the path
// off one of those produced https://host/callback and auth rejected it as
// an unregistered redirect_uri. only take the path when its actually a
// path — schemeless, or a real http(s) url.
var path = "/auth/callback";
final parsed = Uri.tryParse(configured);
if (parsed != null && parsed.path.isNotEmpty) {
final isWebUrl = parsed.scheme == "http" || parsed.scheme == "https";
if (!parsed.hasScheme || isWebUrl) {
path = parsed.path;
}
}
return "${loc.origin}$path";
}
Future<void> navigateTo(String url) async {
web.window.location.assign(url);
}
Uri? currentUri() => Uri.parse(web.window.location.href);
}
+42
View File
@@ -0,0 +1,42 @@
import "package:flutter_secure_storage/flutter_secure_storage.dart";
// small key/value seam so the token storage backend is swappable. the default
// is flutter_secure_storage which covers mobile + desktop properly and falls
// back to a best-effort impl on web. anyone embedding the SDK can hand us their
// own (e.g. an in-memory one for tests, or shared_prefs if they dont care).
abstract class TokenStore {
Future<String?> read(String key);
Future<void> write(String key, String value);
Future<void> delete(String key);
}
class SecureTokenStore implements TokenStore {
SecureTokenStore({FlutterSecureStorage? storage})
: _storage = storage ?? const FlutterSecureStorage();
final FlutterSecureStorage _storage;
@override
Future<String?> read(String key) => _storage.read(key: key);
@override
Future<void> write(String key, String value) =>
_storage.write(key: key, value: value);
@override
Future<void> delete(String key) => _storage.delete(key: key);
}
// handy for tests, or platforms where you explicitly dont want persistence.
class MemoryTokenStore implements TokenStore {
final Map<String, String> _m = {};
@override
Future<String?> read(String key) async => _m[key];
@override
Future<void> write(String key, String value) async => _m[key] = value;
@override
Future<void> delete(String key) async => _m.remove(key);
}
+28
View File
@@ -0,0 +1,28 @@
name: garage_auth
description: "Sign in with Garage — OIDC PKCE auth core, token storage + refresh, and a shared authed HTTP client for Garage apps."
version: 0.1.0
publish_to: 'none'
environment:
sdk: ^3.5.0
flutter: ">=3.5.0"
dependencies:
flutter:
sdk: flutter
http: ^1.5.0
crypto: ^3.0.6
flutter_secure_storage: ">=9.2.2 <12.0.0"
url_launcher: ^6.3.1
# only pulled in on web builds for reading the callback url / navigating the tab
web: ^1.1.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
flutter: