blob: e3ca3bea2a904edd2934d563a6c2390cefb514ce [file] [log] [blame]
// 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 'dart:io';
import 'package:appengine/appengine.dart';
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 [HttpRequest]s from the Dashboard
///
/// There are two types of authentication this class supports:
///
/// 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.
///
/// 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 DashboardAuthentication implements AuthenticationProvider {
DashboardAuthentication({
required Config config,
required FirebaseJwtValidator firebaseJwtValidator,
required FirestoreService firestore,
ClientContextProvider clientContextProvider = Providers.serviceScopeContext,
HttpClientProvider httpClientProvider = Providers.freshHttpClient,
}) {
_authenticationChain.addAll([
DashboardCronAuthentication(clientContextProvider: clientContextProvider),
DashboardFirebaseAuthentication(
config: config,
validator: firebaseJwtValidator,
clientContextProvider: clientContextProvider,
firestore: firestore,
),
]);
}
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 DashboardFirebaseAuthentication implements AuthenticationProvider {
DashboardFirebaseAuthentication({
required Config config,
required FirebaseJwtValidator validator,
required FirestoreService firestore,
ClientContextProvider clientContextProvider = Providers.serviceScopeContext,
}) : _config = config,
_validator = validator,
_firestore = firestore,
_clientContextProvider = clientContextProvider;
/// 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 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(HttpRequest request) async {
try {
if (request.headers.value('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 _isAllowed(_config, token.email)) {
return AuthenticatedContext(
clientContext: clientContext,
email: token.email!,
);
}
}
throw Unauthenticated(
'${token.email} is not authorized to access the dashboard',
);
}
Future<bool> _isAllowed(Config config, String? email) async {
if (email == null) {
return false;
}
final account = await Account.getByEmail(_firestore, email: email);
return account != null;
}
}
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(HttpRequest request) async {
if (request.headers.value('X-Appengine-Cron') == 'true') {
return AuthenticatedContext(
clientContext: _clientContextProvider(),
email: 'CRON_JOB',
);
}
throw const Unauthenticated('Not a cron job');
}
}