discoverTenant function
Resolves discovery info via Control Plane.
If forceRefresh is false, a non-expired cached result is used.
Implementation
Future<DiscoveryRecord> discoverTenant({
String? controlPlaneUrl,
String? firebaseToken,
String? tenantHint,
bool forceRefresh = false,
Duration timeout = const Duration(seconds: 10),
File? cacheFile,
}) async {
final cpUrl = (controlPlaneUrl == null || controlPlaneUrl.trim().isEmpty)
? getControlPlaneUrl()
: controlPlaneUrl.trim();
KumihoCredentials? credentials;
String? token;
if (firebaseToken != null && firebaseToken.trim().isNotEmpty) {
token = firebaseToken.trim();
} else {
// Prefer explicit env var for discovery.
final envToken = Platform.environment[AuthEnvVars.firebaseToken];
if (envToken != null && envToken.trim().isNotEmpty) {
token = envToken.trim();
} else {
// Fall back to cached credentials, optionally auto-refreshing.
credentials = loadCredentials();
final refreshed = await autoRefreshCredentials(credentials);
credentials = refreshed ?? credentials;
token = credentials?.idToken;
}
}
if (token == null || token.trim().isEmpty) {
throw ArgumentError(
'A Firebase ID token is required for discovery. '
'Set KUMIHO_FIREBASE_ID_TOKEN or login via kumiho-cli '
'(~/.kumiho/kumiho_authentication.json).',
);
}
final file = cacheFile ?? getDefaultDiscoveryCacheFile();
final key = (tenantHint == null || tenantHint.trim().isEmpty)
? _defaultCacheKey
: tenantHint.trim();
if (!forceRefresh) {
final cached = _loadFromCache(file, key);
if (cached != null) return cached;
}
final url = _buildDiscoveryUrl(cpUrl);
final body = <String, Object>{
if (tenantHint != null && tenantHint.trim().isNotEmpty)
'tenant_hint': tenantHint.trim(),
};
Future<http.Response> postWithToken(String bearer) {
return http
.post(
url,
headers: {
'Authorization': 'Bearer $bearer',
'Content-Type': 'application/json',
},
body: jsonEncode(body),
)
.timeout(timeout);
}
// Build the ordered list of bearer-token candidates to try. Mirrors Python's
// `_discovery_token_candidates`: the resolved token first, plus the Firebase
// ID token as a fallback when the resolved token is a control-plane bearer
// (a control-plane token may not be accepted by the discovery endpoint, so we
// fall back to the Firebase token if it differs).
final resolvedToken = token;
final candidates = <String>[resolvedToken];
if (_isControlPlaneToken(resolvedToken)) {
final firebase = loadFirebaseToken();
if (firebase != null &&
firebase.isNotEmpty &&
!candidates.contains(firebase)) {
candidates.add(firebase);
}
}
http.Response? response;
// Try each candidate token in order, using the first that succeeds. On an
// error response we remember it and fall through to the next candidate; the
// last error is surfaced only after all candidates are exhausted.
for (var candidate = 0; candidate < candidates.length; candidate++) {
var bearer = candidates[candidate];
var attempt = await postWithToken(bearer);
// If the cached token is expired/invalid, retry once after forcing refresh.
if (attempt.statusCode == 401 &&
firebaseToken == null &&
(Platform.environment[AuthEnvVars.firebaseToken]?.trim().isEmpty ??
true) &&
credentials != null &&
credentials.refreshToken.isNotEmpty) {
final bodyText = attempt.body;
if (bodyText.contains('invalid_id_token')) {
final refreshed =
await autoRefreshCredentials(credentials, forceRefresh: true);
if (refreshed != null &&
refreshed.idToken.isNotEmpty &&
refreshed.idToken != bearer) {
bearer = refreshed.idToken;
attempt = await postWithToken(bearer);
}
}
}
response = attempt;
if (attempt.statusCode < 400) {
break;
}
}
if (response == null || response.statusCode >= 400) {
final body = response?.body ?? '';
final snippet = body.length > 200 ? body.substring(0, 200) : body;
throw HttpException(
'Discovery endpoint returned ${response?.statusCode ?? 0}: $snippet',
uri: url,
);
}
final decoded = jsonDecode(response.body);
final record = _parseDiscoveryRecord(decoded);
// Cache the raw JSON payload so we can re-parse it later.
if (decoded is Map<String, dynamic>) {
_storeToCache(file, key, decoded);
}
return record;
}