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
+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);
}