blob: 730abd538fb3b7051e7accce5b98209575f6150a [file] [log] [blame]
// Copyright 2025 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:async';
import 'dart:io';
import 'package:appengine/appengine.dart';
import 'package:cocoon_server/logging.dart';
import 'package:github/github.dart';
import 'package:meta/meta.dart';
import '../../cocoon_service.dart';
import '../foundation/providers.dart';
import '../foundation/typedefs.dart';
import '../model/google/token_info.dart';
import '../service/firebase_jwt_validator.dart';
import 'dashboard_authentication.dart';
import 'exceptions.dart';
/// Class capable of authenticating [HttpRequest]s from the Checkrun page.
///
/// There are two types of authentication this class supports:
///
/// 1. If the request has the `'X-Flutter-IdToken'` HTTP header
/// set to a valid encrypted JWT token, then the request will be authenticated
/// as a user account.
///
/// @google.com accounts can call APIs using curl and gcloud.
/// E.g. curl '<api_url>' -H "X-Flutter-IdToken: $(gcloud auth print-identity-token)"
///
/// User accounts are only authorized if the user is either a "@google.com"
/// account or is an [AllowedAccount] in Cocoon's Firestore.
///
/// 2. If the request has github.com token, then the request will be authenticated
/// as a GitHub user account.
///
/// If none of the above authentication methods yield an authenticated
/// request, then the request is unauthenticated, and any call to
/// [authenticate] will throw an [Unauthenticated] exception.
///
/// See also:
///
/// * <https://cloud.google.com/appengine/docs/standard/python/reference/request-response-headers>
@immutable
interface class CheckrunAuthentication implements AuthenticationProvider {
CheckrunAuthentication({
required CacheService cache,
required Config config,
required FirebaseJwtValidator firebaseJwtValidator,
required FirestoreService firestore,
ClientContextProvider clientContextProvider = Providers.serviceScopeContext,
HttpClientProvider httpClientProvider = Providers.freshHttpClient,
}) {
_authenticationChain.addAll([
DashboardFirebaseAuthentication(
cache: cache,
validator: firebaseJwtValidator,
clientContextProvider: clientContextProvider,
firestore: firestore,
),
GithubAuthentication(
cache: cache,
config: config,
validator: firebaseJwtValidator,
clientContextProvider: clientContextProvider,
),
]);
}
final _authenticationChain = <AuthenticationProvider>[];
/// Authenticates the specified [request] and returns the associated
/// [AuthenticatedContext].
///
/// See the class documentation on [AuthenticationProvider] for a discussion
/// of the different types of authentication that are accepted.
///
/// This will throw an [Unauthenticated] exception if the request is
/// unauthenticated.
@override
Future<AuthenticatedContext> authenticate(HttpRequest request) async {
/// Walk through the providers
for (final provider in _authenticationChain) {
try {
return await provider.authenticate(request);
} on Unauthenticated {
// nothing
}
}
throw const Unauthenticated('User is not signed in');
}
}
/// Class capable of authenticating [HttpRequest]s from the Checkrun page.
class GithubAuthentication implements AuthenticationProvider {
GithubAuthentication({
required CacheService cache,
required Config config,
required FirebaseJwtValidator validator,
ClientContext Function() clientContextProvider =
Providers.serviceScopeContext,
}) : _cache = cache,
_config = config,
_validator = validator,
_clientContextProvider = clientContextProvider;
final CacheService _cache;
/// The Cocoon config, guaranteed to be non-null.
final Config _config;
/// Provides the App Engine client context as part of the
/// [AuthenticatedContext].
///
/// This is guaranteed to be non-null.
final ClientContextProvider _clientContextProvider;
final FirebaseJwtValidator _validator;
/// Attempt to validate a JWT as a Firebase token.
/// And then validate whether the token has flutter repo write permissions.
@override
Future<AuthenticatedContext> authenticate(HttpRequest request) async {
try {
if (request.headers.value('X-Flutter-IdToken')
case final idTokenFromHeader?) {
final token = await _validator.decodeAndVerify(idTokenFromHeader);
log.info('authing with github.com');
return authenticateGithub(
token,
clientContext: _clientContextProvider(),
);
}
} on JwtException {
// do nothing while in transition
}
throw const Unauthenticated('Not a Firebase token');
}
@visibleForTesting
Future<AuthenticatedContext> authenticateGithub(
TokenInfo token, {
required ClientContext clientContext,
}) async {
if (await _isGithubAllowedCached(
token.firebase?.identities?['github.com']?.first,
)) {
return AuthenticatedContext(
clientContext: clientContext,
email: token.email!,
);
}
throw Unauthenticated(
'${token.email} is not authorized to access the checkrun',
);
}
Future<bool> _isGithubAllowed(String? accountId) async {
if (accountId == null) {
return false;
}
final ghService = _config.createGithubServiceWithToken(
await _config.githubOAuthToken,
);
final user = await ghService.getUserByAccountId(accountId);
if (user.login == null) {
return false;
}
return await ghService.hasUserWritePermissions(
RepositorySlug('flutter', 'flutter'),
user.login!,
);
}
Future<bool> _isGithubAllowedCached(String? accountId) async {
final bytes = await _cache.getOrCreateWithLocking(
'github_account_allowed',
accountId ?? 'null_accountId',
createFn: () async => (await _isGithubAllowed(accountId)).toUint8List(),
);
return bytes?.toBool() ?? false;
}
}