blob: 38f8e041e57c42b2c573d30aaef9159345b3b775 [file] [edit]
// Copyright 2019 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 'package:cocoon_server/logging.dart';
import 'package:meta/meta.dart';
import '../../cocoon_service.dart';
import '../foundation/providers.dart';
import '../foundation/typedefs.dart';
import '../model/firestore/account.dart';
import '../model/google/token_info.dart';
import '../service/firebase_jwt_validator.dart';
import 'exceptions.dart';
/// Class capable of authenticating [Request]s from multiple sources.
///
/// There are three types of authentication this class can chain:
///
/// 1. If the request has the `'X-Appengine-Cron'` HTTP header set to "true",
/// then the request will be authenticated as an App Engine cron job.
///
/// The `'X-Appengine-Cron'` HTTP header is set automatically by App Engine
/// and will be automatically stripped from the request by the App Engine
/// runtime if the request originated from anything other than a cron job.
/// Thus, the header is safe to trust as an authentication indicator.
///
/// 2. 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.
///
/// 3. 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 ChainOfAuthentication implements AuthenticationProvider {
/// Creates an AuthenticationProvider with a chain of responsibility.
ChainOfAuthentication.forProviders(List<AuthenticationProvider> authProviders)
: _authenticationChain = authProviders;
final List<AuthenticationProvider> _authenticationChain;
/// 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(Request 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 DashboardFirebaseAuthentication implements AuthenticationProvider {
DashboardFirebaseAuthentication({
required CacheService cache,
required FirebaseJwtValidator validator,
required FirestoreService firestore,
ClientContext Function() clientContextProvider =
Providers.serviceScopeContext,
}) : _cache = cache,
_validator = validator,
_firestore = firestore,
_clientContextProvider = clientContextProvider;
final CacheService _cache;
/// Provides the App Engine client context as part of the
/// [AuthenticatedContext].
///
/// This is guaranteed to be non-null.
final ClientContextProvider _clientContextProvider;
final FirestoreService _firestore;
final FirebaseJwtValidator _validator;
/// Attempt to validate a JWT as a Firebase token.
///
/// NOTE: Until we fully switch over to Firebase; we could have a mix of JWT
/// coming into cocoon. This should not be fatal.
@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('authed with firebase: ${token.email}');
return authenticateFirebase(
token,
clientContext: _clientContextProvider(),
);
}
} on JwtException {
// do nothing while in transition
}
throw const Unauthenticated('Not a Firebase token');
}
@visibleForTesting
Future<AuthenticatedContext> authenticateFirebase(
TokenInfo token, {
required ClientContext clientContext,
}) async {
if (token.email case final email?) {
if (email.endsWith('@google.com') ||
await _isAllowedCached(token.email)) {
return AuthenticatedContext(
clientContext: clientContext,
email: token.email!,
);
}
}
throw Unauthenticated(
'${token.email} is not authorized to access the dashboard',
);
}
Future<bool> _isAllowed(String? email) async {
if (email == null) {
return false;
}
final account = await Account.getByEmail(_firestore, email: email);
return account != null;
}
Future<bool> _isAllowedCached(String? email) async {
final bytes = await _cache.getOrCreateWithLocking(
'account_allowed',
email ?? 'null_email',
createFn: () async => (await _isAllowed(email)).toUint8List(),
);
return bytes?.toBool() ?? false;
}
}
class DashboardCronAuthentication implements AuthenticationProvider {
const DashboardCronAuthentication({
ClientContextProvider clientContextProvider = Providers.serviceScopeContext,
}) : _clientContextProvider = clientContextProvider;
/// Provides the App Engine client context as part of the
/// [AuthenticatedContext].
///
/// This is guaranteed to be non-null.
final ClientContextProvider _clientContextProvider;
@override
Future<AuthenticatedContext> authenticate(Request request) async {
if (request.header('X-Appengine-Cron') == 'true') {
return AuthenticatedContext(
clientContext: _clientContextProvider(),
email: 'CRON_JOB',
);
}
throw const Unauthenticated('Not a cron job');
}
}