blob: a7a1692c1ecf518ee7ba8b810b6e0e59f4950a74 [file]
// 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:typed_data';
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 'exceptions.dart';
/// Class capable of authenticating [Request]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(Request request) async {
try {
if (request.header('X-Flutter-IdToken') case final idTokenFromHeader?) {
final token = await _validator.decodeAndVerify(idTokenFromHeader);
log.info('authing with github.com');
return await 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 {
final githubLogin = await _getGithubLoginCached(
token.firebase?.identities?['github.com']?.first,
);
if (await _isGithubAllowedCached(
token.firebase?.identities?['github.com']?.first,
githubLogin,
)) {
return AuthenticatedContext(
clientContext: clientContext,
email: token.email!,
githubLogin: githubLogin,
);
}
throw Unauthenticated(
'${token.email} is not authorized to access the checkrun',
);
}
Future<String?> _getGithubLogin(String? accountId) async {
if (accountId == null) {
return null;
}
final ghService = _config.createGithubServiceWithToken(
await _config.githubOAuthToken,
);
final user = await ghService.getUserByAccountId(accountId);
return user.login;
}
Future<String?> _getGithubLoginCached(String? accountId) async {
final bytes = await _cache.getOrCreateWithLocking(
'github_account_login',
accountId ?? 'null_accountId',
createFn: () async => Uint8List.fromList(
(await _getGithubLogin(accountId))?.codeUnits ?? [],
),
);
final login = String.fromCharCodes(bytes!);
return login.isEmpty ? null : login;
}
Future<bool> _isGithubAllowed(String? accountId, String? githubLogin) async {
final login = githubLogin ?? await _getGithubLogin(accountId);
if (login == null) {
return false;
}
final ghService = _config.createGithubServiceWithToken(
await _config.githubOAuthToken,
);
return await ghService.hasUserWritePermissions(
RepositorySlug('flutter', 'flutter'),
login,
);
}
Future<bool> _isGithubAllowedCached(
String? accountId,
String? githubLogin,
) async {
final bytes = await _cache.getOrCreateWithLocking(
'github_account_allowed',
accountId ?? 'null_accountId',
createFn: () async =>
(await _isGithubAllowed(accountId, githubLogin)).toUint8List(),
);
return bytes?.toBool() ?? false;
}
}