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:
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user