Add firebase Deauth

This commit is contained in:
Bradley Schofield
2023-08-01 15:27:46 +01:00
parent 2da76cdb59
commit 8eebb55ccd
12 changed files with 223 additions and 260 deletions
+3 -1
View File
@@ -86,4 +86,6 @@ _APP_GRAPHQL_MAX_COMPLEXITY=250
_APP_GRAPHQL_MAX_DEPTH=3
DOCKERHUB_PULL_USERNAME=
DOCKERHUB_PULL_PASSWORD=
DOCKERHUB_PULL_EMAIL=
DOCKERHUB_PULL_EMAIL=
_APP_MIGRATIONS_FIREBASE_CLIENT_ID=4193949439-dc9stpf5pc5fihn0rpt33ng2hj4n1304.apps.googleusercontent.com
_APP_MIGRATIONS_FIREBASE_CLIENT_SECRET=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
-208
View File
@@ -1,208 +0,0 @@
<?php
class Firebase
{
protected string $endpoint = 'https://firebase.googleapis.com/';
protected string $accessToken;
protected string $refreshToken;
protected string $clientId;
protected $cache;
/**
* Global Headers
*
* @var array<string, string>
*/
protected $headers = ['content-type' => 'application/json'];
public function __construct($cache)
{
$this->cache = $cache;
}
/**
* Firebase Initialisation with access token generation.
*/
public function initialiseVariables(string $clientId, string $clientSecret): void
{
$this->clientId = $clientId;
$response = $this->cache->load($clientId, 60 * 9); // 10 minutes, but 1 minute earlier to be safe
if ($response == false) {
$this->generateAccessToken($clientSecret, $clientId, $this->refreshToken);
$tokens = \json_encode([
'refrershToken' => $this->refreshToken,
'accessToken' => $this->accessToken,
]) ?: '{}';
$this->cache->save($clientId, $tokens);
} else {
$parsed = \json_decode($response, true);
$this->refreshToken = $parsed['refresh_token'];
$this->accessToken = $parsed['access_token'];
}
}
/**
* Generate Access Token
*/
protected function generateAccessToken(string $privateKey, string $clientId, string $token): void
{
$res = $this->call('POST', 'https://oauth2.googleapis.com/token', [], [
'client_id' => $clientId,
'client_secret' => $privateKey,
'refresh_token' => $token,
'grant_type' => 'refresh_token',
]);
$this->accessToken = $res['body']['access_token'];
}
/**
* Call
*
* Make an API call
*
* @param string $method
* @param string $path
* @param array<mixed> $params
* @param array<string, string> $headers
* @param bool $decode
* @return array<mixed>
*
* @throws Exception
*/
protected function call(string $method, string $path = '', array $headers = [], array $params = [], bool $decode = true)
{
$headers = array_merge($this->headers, $headers);
$ch = curl_init($this->endpoint . $path . (($method == 'GET' && !empty($params)) ? '?' . http_build_query($params) : ''));
if (!$ch) {
throw new Exception('Curl failed to initialize');
}
$responseHeaders = [];
$responseStatus = -1;
$responseType = '';
$responseBody = '';
switch ($headers['content-type']) {
case 'application/json':
$query = json_encode($params);
break;
case 'multipart/form-data':
$query = $this->flatten($params);
break;
case 'application/graphql':
$query = $params[0];
break;
default:
$query = http_build_query($params);
break;
}
foreach ($headers as $i => $header) {
$headers[] = $i . ':' . $header;
unset($headers[$i]);
}
curl_setopt($ch, CURLOPT_PATH_AS_IS, 1);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_USERAGENT, 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.77 Safari/537.36');
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 0);
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
curl_setopt($ch, CURLOPT_HEADERFUNCTION, function ($curl, $header) use (&$responseHeaders) {
$len = strlen($header);
$header = explode(':', $header, 2);
if (count($header) < 2) { // ignore invalid headers
return $len;
}
$responseHeaders[strtolower(trim($header[0]))] = trim($header[1]);
return $len;
});
if ($method != 'GET') {
curl_setopt($ch, CURLOPT_POSTFIELDS, $query);
}
$responseBody = \curl_exec($ch) ?: '';
if ($responseBody === true) {
$responseBody = '';
}
$responseType = $responseHeaders['content-type'] ?? '';
$responseStatus = \curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($decode) {
$length = strpos($responseType, ';') ?: 0;
switch (substr($responseType, 0, $length)) {
case 'application/json':
$json = \json_decode($responseBody, true);
if ($json === null) {
throw new Exception('Failed to parse response: ' . $responseBody);
}
$responseBody = $json;
$json = null;
break;
}
}
if ((curl_errno($ch)/* || 200 != $responseStatus*/)) {
throw new Exception(curl_error($ch) . ' with status code ' . $responseStatus, $responseStatus);
}
curl_close($ch);
$responseHeaders['status-code'] = $responseStatus;
if ($responseStatus === 500) {
echo 'Server error(' . $method . ': ' . $path . '. Params: ' . json_encode($params) . '): ' . json_encode($responseBody) . "\n";
}
return [
'headers' => $responseHeaders,
'body' => $responseBody,
];
}
/**
* Flatten params array to PHP multiple format
*
* @param array<mixed> $data
* @param string $prefix
* @return array<mixed>
*/
protected function flatten(array $data, string $prefix = ''): array
{
$output = [];
foreach ($data as $key => $value) {
$finalKey = $prefix ? "{$prefix}[{$key}]" : $key;
if (is_array($value)) {
$output += $this->flatten($value, $finalKey); // @todo: handle name collision here if needed
} else {
$output[$finalKey] = $value;
}
}
return $output;
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+171 -23
View File
@@ -9,7 +9,6 @@ use Utopia\Database\Helpers\ID;
use Appwrite\Utopia\Database\Validator\Queries\Migrations;
use Appwrite\Utopia\Request;
use Appwrite\Utopia\Response;
use Firebase as GlobalFirebase;
use Utopia\App;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
@@ -265,10 +264,9 @@ App::get('/v1/migrations/appwrite/report')
}
});
App::post('/v1/migrations/firebase')
->groups(['api', 'migrations'])
->desc('Migrate Firebase Data')
->desc('Migrate Firebase Data (Service Account)')
->label('scope', 'migrations.write')
->label('event', 'migrations.create')
->label('audits.event', 'migration.create')
@@ -316,6 +314,91 @@ App::post('/v1/migrations/firebase')
->dynamic($migration, Response::MODEL_MIGRATION);
});
App::post('/v1/migrations/firebaseOAuth')
->groups(['api', 'migrations'])
->desc('Migrate Firebase Data (OAuth)')
->label('scope', 'migrations.write')
->label('event', 'migrations.create')
->label('audits.event', 'migration.create')
->label('sdk.auth', [APP_AUTH_TYPE_KEY])
->label('sdk.namespace', 'migrations')
->label('sdk.method', 'migrateFirebaseOAuth')
->label('sdk.description', '/docs/references/migrations/migration-firebase.md')
->label('sdk.response.code', Response::STATUS_CODE_ACCEPTED)
->label('sdk.response.type', Response::CONTENT_TYPE_JSON)
->label('sdk.response.model', Response::MODEL_MIGRATION)
->param('resources', [], new ArrayList(new WhiteList(Firebase::getSupportedResources())), 'List of resources to migrate')
->param('projectId', '', new Text(65536), "Project ID of the Firebase Project")
->inject('response')
->inject('dbForProject')
->inject('dbForConsole')
->inject('project')
->inject('user')
->inject('events')
->inject('request')
->action(function (array $resources, string $projectId, Response $response, Database $dbForProject, Database $dbForConsole, Document $project, Document $user, Event $eventsInstance, Request $request) {
$firebase = new OAuth2Firebase(
App::getEnv('_APP_MIGRATIONS_FIREBASE_CLIENT_ID', ''),
App::getEnv('_APP_MIGRATIONS_FIREBASE_CLIENT_SECRET', ''),
$request->getProtocol() . '://' . $request->getHostname() . "/v1/migrations/firebase/redirect"
);
$accessToken = $user->getAttribute('migrationsFirebaseAccessToken');
$refreshToken = $user->getAttribute('migrationsFirebaseRefreshToken');
$accessTokenExpiry = $user->getAttribute('migrationsFirebaseAccessTokenExpiry');
$isExpired = new \DateTime($accessTokenExpiry) < new \DateTime('now');
if ($isExpired) {
$firebase->refreshTokens($refreshToken);
$accessToken = $firebase->getAccessToken('');
$refreshToken = $firebase->getRefreshToken('');
$verificationId = $firebase->getUserID($accessToken);
if (empty($verificationId)) {
throw new Exception(Exception::GENERAL_RATE_LIMIT_EXCEEDED, "Another request is currently refreshing OAuth token. Please try again.");
}
$user = $user
->setAttribute('migrationsFirebaseAccessToken', $accessToken)
->setAttribute('migrationsFirebaseRefreshToken', $refreshToken)
->setAttribute('migrationsFirebaseAccessTokenExpiry', DateTime::addSeconds(new \DateTime(), (int)$firebase->getAccessTokenExpiry('')));
$dbForConsole->updateDocument('users', $user->getId(), $user);
}
$firebase->createServiceAccount($accessToken, $projectId);
// $migration = $dbForProject->createDocument('migrations', new Document([
// '$id' => ID::unique(),
// 'status' => 'pending',
// 'stage' => 'init',
// 'source' => Firebase::getName(),
// 'credentials' => [
// 'serviceAccount' => $serviceAccount,
// ],
// 'resources' => $resources,
// 'statusCounters' => '{}',
// 'resourceData' => "{}",
// 'errors' => []
// ]));
// $eventsInstance->setParam('migrationId', $migration->getId());
// // Trigger Transfer
// $event = new Migration();
// $event
// ->setMigration($migration)
// ->setProject($project)
// ->setUser($user)
// ->trigger();
// $response
// ->setStatusCode(Response::STATUS_CODE_ACCEPTED)
// ->dynamic($migration, Response::MODEL_MIGRATION);
});
App::get('/v1/migrations/firebase/report')
->groups(['api', 'migrations'])
->desc('Generate a report on Firebase Data')
@@ -372,20 +455,16 @@ App::get('/v1/migrations/firebase/connect')
'redirect' => $redirect
]);
// replace firebase url state with migrationState in user prefs attribute
$prefs = $user->getAttribute('prefs', []);
$prefs['migrationState'] = $state;
$user->setAttribute('prefs', $prefs);
$dbForConsole->updateDocument('users', $user->getId(), $user);
$url = "https://accounts.google.com/o/oauth2/v2/auth" .
"?access_type=offline" .
"&scope=" . urlencode("https://www.googleapis.com/auth/firebase https://www.googleapis.com/auth/datastore https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/identitytoolkit") .
"&response_type=code" .
"&client_id=" . urlencode(App::getEnv('_APP_MIGRATIONS_FIREBASE_CLIENT_ID')) .
"&redirect_uri=" . $request->getProtocol() . '://' . $request->getHostname() . "/v1/migrations/firebase/redirect" .
"&state=" . urlencode($state) .
"&prompt=consent";
$oauth2 = new OAuth2Firebase(
App::getEnv('_APP_MIGRATIONS_FIREBASE_CLIENT_ID', ''),
App::getEnv('_APP_MIGRATIONS_FIREBASE_CLIENT_SECRET', ''),
$request->getProtocol() . '://' . $request->getHostname() . "/v1/migrations/firebase/redirect");
$url = $oauth2->getLoginURL();
$response
->addHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
@@ -399,13 +478,12 @@ App::get('/v1/migrations/firebase/redirect')
->label('scope', 'public')
->label('error', __DIR__ . '/../../views/general/error.phtml')
->param('code', '', new Text(2048), 'OAuth2 code.', true)
->inject('firebase')
->inject('user')
->inject('project')
->inject('request')
->inject('response')
->inject('dbForConsole')
->action(function (string $code, GlobalFirebase $firebase, Document $user, Document $project, Request $request, Response $response, Database $dbForConsole) {
->action(function (string $code, Document $user, Document $project, Request $request, Response $response, Database $dbForConsole) {
$prefs = $user->getAttribute('prefs', []);
$state = $prefs['migrationState'] ?? '{}';
$prefs['migrationState'] = '';
@@ -423,7 +501,7 @@ App::get('/v1/migrations/firebase/redirect')
$project = $dbForConsole->getDocument('projects', $projectId);
if (empty($redirect)) {
$redirect = $request->getProtocol() . '://' . $request->getHostname() . "/console/project-$projectId/settings/git-installations";
$redirect = $request->getProtocol() . '://' . $request->getHostname() . "/console/project-$projectId/settings/migrations";
}
if ($project->isEmpty()) {
@@ -436,7 +514,12 @@ App::get('/v1/migrations/firebase/redirect')
// OAuth Authroization
if (!empty($code)) {
$oauth2 = new OAuth2Firebase(App::getEnv('_APP_MIGRATIONS_FIREBASE_CLIENT_ID', ''), App::getEnv('_APP_MIGRATIONS_FIREBASE_CLIENT_SECRET', ''), "");
$oauth2 = new OAuth2Firebase(
App::getEnv('_APP_MIGRATIONS_FIREBASE_CLIENT_ID', ''),
App::getEnv('_APP_MIGRATIONS_FIREBASE_CLIENT_SECRET', ''),
$request->getProtocol() . '://' . $request->getHostname() . "/v1/migrations/firebase/redirect"
);
$accessToken = $oauth2->getAccessToken($code);
$refreshToken = $oauth2->getRefreshToken($code);
$accessTokenExpiry = $oauth2->getAccessTokenExpiry($code);
@@ -457,26 +540,91 @@ App::get('/v1/migrations/firebase/redirect')
App::get('/v1/migrations/firebase/projects')
->desc('List Firebase Projects')
->groups(['api', 'vcs'])
->groups(['api', 'migrations'])
->label('scope', 'public')
->label('sdk.namespace', 'vcs')
->label('sdk.namespace', 'migrations')
->label('sdk.method', 'listFirebaseProjects')
->label('sdk.description', '')
->label('sdk.response.code', Response::STATUS_CODE_OK)
->label('sdk.response.type', Response::CONTENT_TYPE_JSON)
->label('sdk.response.model', Response::MODEL_MIGRATION_FIREBASE_PROJECT_LIST)
->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true)
->inject('firebase')
->inject('user')
->inject('response')
->inject('project')
->inject('dbForConsole')
->action(function (string $search, GlobalFirebase $firebase, Response $response, Document $project, Database $dbForConsole) {
if (empty($search)) {
$search = "";
->inject('request')
->action(function (Document $user, Response $response, Document $project, Database $dbForConsole, Request $request) {
$firebase = new OAuth2Firebase(
App::getEnv('_APP_MIGRATIONS_FIREBASE_CLIENT_ID', ''),
App::getEnv('_APP_MIGRATIONS_FIREBASE_CLIENT_SECRET', ''),
$request->getProtocol() . '://' . $request->getHostname() . "/v1/migrations/firebase/redirect"
);
$accessToken = $user->getAttribute('migrationsFirebaseAccessToken');
$refreshToken = $user->getAttribute('migrationsFirebaseRefreshToken');
$accessTokenExpiry = $user->getAttribute('migrationsFirebaseAccessTokenExpiry');
$isExpired = new \DateTime($accessTokenExpiry) < new \DateTime('now');
if ($isExpired) {
$firebase->refreshTokens($refreshToken);
$accessToken = $firebase->getAccessToken('');
$refreshToken = $firebase->getRefreshToken('');
$verificationId = $firebase->getUserID($accessToken);
if (empty($verificationId)) {
throw new Exception(Exception::GENERAL_RATE_LIMIT_EXCEEDED, "Another request is currently refreshing OAuth token. Please try again.");
}
$user = $user
->setAttribute('migrationsFirebaseAccessToken', $accessToken)
->setAttribute('migrationsFirebaseRefreshToken', $refreshToken)
->setAttribute('migrationsFirebaseAccessTokenExpiry', DateTime::addSeconds(new \DateTime(), (int)$firebase->getAccessTokenExpiry('')));
$dbForConsole->updateDocument('users', $user->getId(), $user);
}
$projects = $firebase->getProjects($accessToken);
$output = [];
foreach ($projects as $project) {
$output[] = [
'displayName' => $project['displayName'],
'projectId' => $project['projectId'],
];
}
$response->dynamic(new Document([
'projects' => $output,
'total' => count($output),
]), Response::MODEL_MIGRATION_FIREBASE_PROJECT_LIST);
});
App::get('/v1/migrations/firebase/deauthorize')
->desc('Revoke Appwrite\'s authorization to access Firebase Projects')
->groups(['api', 'migrations'])
->label('scope', 'public')
->label('sdk.namespace', 'migrations')
->label('sdk.method', 'firebaseDeauthorize')
->label('sdk.description', '')
->label('sdk.response.code', Response::STATUS_CODE_OK)
->label('sdk.response.type', Response::CONTENT_TYPE_JSON)
->inject('user')
->inject('response')
->inject('project')
->inject('dbForConsole')
->inject('request')
->action(function (Document $user, Response $response, Document $project, Database $dbForConsole, Request $request) {
$user = $user
->setAttribute('migrationsFirebaseAccessToken', '')
->setAttribute('migrationsFirebaseRefreshToken', '')
->setAttribute('migrationsFirebaseAccessTokenExpiry', '');
$dbForConsole->updateDocument('users', $user->getId(), $user);
$response->noContent();
});
App::post('/v1/migrations/supabase')
+1 -5
View File
@@ -1179,8 +1179,4 @@ App::setResource('requestTimestamp', function ($request) {
}
}
return $requestTimestamp;
}, ['request']);
App::setResource('firebase', function ($cache) {
return new Firebase($cache);
}, ['cache']);
}, ['request']);
+41 -16
View File
@@ -41,10 +41,13 @@ class Firebase extends OAuth2
public function getLoginURL(): string
{
return 'https://accounts.google.com/o/oauth2/v2/auth?' . \http_build_query([
'access_type' => 'offline',
'client_id' => $this->appID,
'redirect_uri' => $this->callback,
'scope' => \implode(' ', $this->getScopes()),
'state' => \json_encode($this->state)
'state' => \json_encode($this->state),
'response_type' => 'code',
'prompt' => 'consent',
]);
}
@@ -69,15 +72,13 @@ class Firebase extends OAuth2
])
);
$output = [];
\parse_str($response, $output);
$this->tokens = $output;
$this->tokens = \json_decode($response, true);
}
return $this->tokens;
}
/**
/**
* @param string $refreshToken
*
* @return array
@@ -173,20 +174,44 @@ class Firebase extends OAuth2
protected function getUser(string $accessToken)
{
if (empty($this->user)) {
$this->user = \json_decode($this->request('GET', 'https://api.github.com/user', ['Authorization: token ' . \urlencode($accessToken)]), true);
$response = $this->request(
'GET',
'https://www.googleapis.com/oauth2/v1/userinfo',
['Authorization: Bearer ' . \urlencode($accessToken)]
);
$emails = $this->request('GET', ' https://www.googleapis.com/oauth2/v1/userinfo?alt=json', ['Authorization: token ' . \urlencode($accessToken)]);
$emails = \json_decode($emails, true);
foreach ($emails as $email) {
if (isset($email['verified']) && $email['verified'] === true) {
$this->user['email'] = $email['email'];
$this->user['verified'] = $email['verified'];
break;
}
}
$this->user = \json_decode($response, true);
}
return $this->user;
}
public function getProjects(string $accessToken): array
{
$projects = $this->request('GET', 'https://firebase.googleapis.com/v1beta1/projects', ['Authorization: Bearer ' . \urlencode($accessToken)]);
$projects = \json_decode($projects, true);
return $projects['results'];
}
public function createServiceAccount(string $accessToken, string $projectID): array
{
$response = $this->request(
'POST',
"https://iam.googleapis.com/v1/projects/{$projectID}/serviceAccounts",
[
'Authorization: Bearer ' . \urlencode($accessToken),
'Content-Type: application/json'
],
json_encode([
'accountId' => 'appwrite-migrations',
'serviceAccount' => [
'displayName' => 'Appwrite Migrations'
]
])
);
return json_decode($response, true);
}
}
+1 -1
View File
@@ -291,7 +291,7 @@ class Response extends SwooleResponse
->setModel(new BaseList('Metric List', self::MODEL_METRIC_LIST, 'metrics', self::MODEL_METRIC, true, false))
->setModel(new BaseList('Variables List', self::MODEL_VARIABLE_LIST, 'variables', self::MODEL_VARIABLE))
->setModel(new BaseList('Migrations List', self::MODEL_MIGRATION_LIST, 'migrations', self::MODEL_MIGRATION))
->setModel(new BaseList('Migrations Firebase Projects List', self::MODEL_MIGRATION_FIREBASE_PROJECT_LIST, 'migrations', self::MODEL_MIGRATION_FIREBASE_PROJECT))
->setModel(new BaseList('Migrations Firebase Projects List', self::MODEL_MIGRATION_FIREBASE_PROJECT_LIST, 'projects', self::MODEL_MIGRATION_FIREBASE_PROJECT))
// Entities
->setModel(new Database())
->setModel(new Collection())