blob: 21d99ee08aaf923db44a9b586c5a95cc5689bec7 [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:convert';
import 'dart:io';
import 'package:appengine/appengine.dart';
import 'package:gcloud/db.dart';
import 'package:http/http.dart' as http;
import 'package:meta/meta.dart';
import '../../cocoon_service.dart';
import '../foundation/providers.dart';
import '../foundation/typedefs.dart';
import '../model/appengine/allowed_account.dart';
import '../model/google/token_info.dart';
import '../service/logging.dart';
import 'exceptions.dart';
/// Class capable of authenticating [HttpRequest]s.
///
/// 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 Datastore.
///
/// 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
class AuthenticationProvider {
const AuthenticationProvider({
required this.config,
this.clientContextProvider = Providers.serviceScopeContext,
this.httpClientProvider = Providers.freshHttpClient,
});
/// 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;
/// Provides the HTTP client that will be used (if necessary) to verify OAuth
/// ID tokens (JWT tokens).
///
/// This is guaranteed to be non-null.
final HttpClientProvider httpClientProvider;
/// 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.
Future<AuthenticatedContext> authenticate(HttpRequest request) async {
final bool isCron = request.headers.value('X-Appengine-Cron') == 'true';
final String? idTokenFromHeader = request.headers.value('X-Flutter-IdToken');
final ClientContext clientContext = clientContextProvider();
if (isCron) {
// Authenticate cron requests
return AuthenticatedContext(clientContext: clientContext);
} else if (idTokenFromHeader != null) {
TokenInfo token;
try {
token = await tokenInfo(request);
} on Unauthenticated {
token = await tokenInfo(request, tokenType: 'access_token');
}
return authenticateToken(token, clientContext: clientContext);
}
throw const Unauthenticated('User is not signed in');
}
/// Gets oauth token information. This method requires the token to be stored in
/// X-Flutter-IdToken header.
Future<TokenInfo> tokenInfo(HttpRequest request, {String tokenType = 'id_token'}) async {
final String? idTokenFromHeader = request.headers.value('X-Flutter-IdToken');
final http.Client client = httpClientProvider();
try {
final http.Response verifyTokenResponse = await client.get(
Uri.https(
'oauth2.googleapis.com',
'/tokeninfo',
<String, String?>{
tokenType: idTokenFromHeader,
},
),
);
if (verifyTokenResponse.statusCode != HttpStatus.ok) {
/// Google Auth API returns a message in the response body explaining why
/// the request failed. Such as "Invalid Token".
log.fine('Token verification failed: ${verifyTokenResponse.statusCode}; ${verifyTokenResponse.body}');
throw const Unauthenticated('Invalid ID token');
}
try {
return TokenInfo.fromJson(json.decode(verifyTokenResponse.body) as Map<String, dynamic>);
} on FormatException {
throw InternalServerError('Invalid JSON: "${verifyTokenResponse.body}"');
}
} finally {
client.close();
}
}
Future<AuthenticatedContext> authenticateToken(TokenInfo token, {required ClientContext clientContext}) async {
// Authenticate as a signed-in Google account via OAuth id token.
final String clientId = await config.oauthClientId;
if (token.audience != clientId && !token.email!.endsWith('@google.com')) {
log.warning('Possible forged token: "${token.audience}" (expected "$clientId")');
throw const Unauthenticated('Invalid ID token');
}
if (token.hostedDomain != 'google.com') {
final bool isAllowed = await _isAllowed(token.email);
if (!isAllowed) {
throw Unauthenticated('${token.email} is not authorized to access the dashboard');
}
}
return AuthenticatedContext(clientContext: clientContext);
}
Future<bool> _isAllowed(String? email) async {
final Query<AllowedAccount> query = config.db.query<AllowedAccount>()
..filter('email =', email)
..limit(20);
return !(await query.run().isEmpty);
}
}
/// Class that represents an authenticated request having been made, and any
/// attached metadata to that request.
///
/// See also:
///
/// * [AuthenticationProvider]
@immutable
class AuthenticatedContext {
/// Creates a new [AuthenticatedContext].
const AuthenticatedContext({
required this.clientContext,
});
/// The App Engine [ClientContext] of the current request.
///
/// This is guaranteed to be non-null.
final ClientContext clientContext;
}