1
0
Fork 0
mirror of synced 2024-09-30 01:08:13 +13:00

Merge pull request #5895 from appwrite/feat-2591-improve-oauth2-error-handling

Improve OAuth2 error handling
This commit is contained in:
Eldad A. Fux 2023-08-01 22:55:58 +03:00 committed by GitHub
commit 05a316bf48
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 175 additions and 38 deletions

View file

@ -195,6 +195,21 @@ return [
'description' => 'Missing ID from OAuth2 provider.',
'code' => 400,
],
Exception::USER_OAUTH2_BAD_REQUEST => [
'name' => Exception::USER_OAUTH2_BAD_REQUEST,
'description' => 'OAuth2 provider rejected the bad request.',
'code' => 400,
],
Exception::USER_OAUTH2_UNAUTHORIZED => [
'name' => Exception::USER_OAUTH2_UNAUTHORIZED,
'description' => 'OAuth2 provider rejected the unauthorized request.',
'code' => 401,
],
Exception::USER_OAUTH2_PROVIDER_ERROR => [
'name' => Exception::USER_OAUTH2_PROVIDER_ERROR,
'description' => 'OAuth2 provider returned some error.',
'code' => 424,
],
/** Teams */
Exception::TEAM_NOT_FOUND => [

View file

@ -2,6 +2,7 @@
use Ahc\Jwt\JWT;
use Appwrite\Auth\Auth;
use Appwrite\Auth\OAuth2\Exception as OAuth2Exception;
use Appwrite\Auth\Validator\Password;
use Appwrite\Auth\Validator\Phone;
use Appwrite\Detector\Detector;
@ -338,11 +339,13 @@ App::get('/v1/account/sessions/oauth2/callback/:provider/:projectId')
->label('docs', false)
->param('projectId', '', new Text(1024), 'Project ID.')
->param('provider', '', new WhiteList(\array_keys(Config::getParam('providers')), true), 'OAuth2 provider.')
->param('code', '', new Text(2048), 'OAuth2 code.')
->param('code', '', new Text(2048, 0), 'OAuth2 code.', true)
->param('state', '', new Text(2048), 'Login state params.', true)
->param('error', '', new Text(2048, 0), 'Error code returned from the OAuth2 provider.', true)
->param('error_description', '', new Text(2048, 0), 'Human-readable text providing additional information about the error returned from the OAuth2 provider.', true)
->inject('request')
->inject('response')
->action(function (string $projectId, string $provider, string $code, string $state, Request $request, Response $response) {
->action(function (string $projectId, string $provider, string $code, string $state, string $error, string $error_description, Request $request, Response $response) {
$domain = $request->getHostname();
$protocol = $request->getProtocol();
@ -351,7 +354,13 @@ App::get('/v1/account/sessions/oauth2/callback/:provider/:projectId')
->addHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
->addHeader('Pragma', 'no-cache')
->redirect($protocol . '://' . $domain . '/v1/account/sessions/oauth2/' . $provider . '/redirect?'
. \http_build_query(['project' => $projectId, 'code' => $code, 'state' => $state]));
. \http_build_query([
'project' => $projectId,
'code' => $code,
'state' => $state,
'error' => $error,
'error_description' => $error_description
]));
});
App::post('/v1/account/sessions/oauth2/callback/:provider/:projectId')
@ -363,11 +372,13 @@ App::post('/v1/account/sessions/oauth2/callback/:provider/:projectId')
->label('docs', false)
->param('projectId', '', new Text(1024), 'Project ID.')
->param('provider', '', new WhiteList(\array_keys(Config::getParam('providers')), true), 'OAuth2 provider.')
->param('code', '', new Text(2048), 'OAuth2 code.')
->param('code', '', new Text(2048, 0), 'OAuth2 code.', true)
->param('state', '', new Text(2048), 'Login state params.', true)
->param('error', '', new Text(2048, 0), 'Error code returned from the OAuth2 provider.', true)
->param('error_description', '', new Text(2048, 0), 'Human-readable text providing additional information about the error returned from the OAuth2 provider.', true)
->inject('request')
->inject('response')
->action(function (string $projectId, string $provider, string $code, string $state, Request $request, Response $response) {
->action(function (string $projectId, string $provider, string $code, string $state, string $error, string $error_description, Request $request, Response $response) {
$domain = $request->getHostname();
$protocol = $request->getProtocol();
@ -376,7 +387,13 @@ App::post('/v1/account/sessions/oauth2/callback/:provider/:projectId')
->addHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
->addHeader('Pragma', 'no-cache')
->redirect($protocol . '://' . $domain . '/v1/account/sessions/oauth2/' . $provider . '/redirect?'
. \http_build_query(['project' => $projectId, 'code' => $code, 'state' => $state]));
. \http_build_query([
'project' => $projectId,
'code' => $code,
'state' => $state,
'error' => $error,
'error_description' => $error_description
]));
});
App::get('/v1/account/sessions/oauth2/:provider/redirect')
@ -394,8 +411,10 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect')
->label('usage.metric', 'sessions.{scope}.requests.create')
->label('usage.params', ['provider:{request.provider}'])
->param('provider', '', new WhiteList(\array_keys(Config::getParam('providers')), true), 'OAuth2 provider.')
->param('code', '', new Text(2048), 'OAuth2 code.')
->param('code', '', new Text(2048, 0), 'OAuth2 code.', true)
->param('state', '', new Text(2048), 'OAuth2 state params.', true)
->param('error', '', new Text(2048, 0), 'Error code returned from the OAuth2 provider.', true)
->param('error_description', '', new Text(2048, 0), 'Human-readable text providing additional information about the error returned from the OAuth2 provider.', true)
->inject('request')
->inject('response')
->inject('project')
@ -403,7 +422,7 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect')
->inject('dbForProject')
->inject('geodb')
->inject('events')
->action(function (string $provider, string $code, string $state, Request $request, Response $response, Document $project, Document $user, Database $dbForProject, Reader $geodb, Event $events) use ($oauthDefaultSuccess) {
->action(function (string $provider, string $code, string $state, string $error, string $error_description, Request $request, Response $response, Document $project, Document $user, Database $dbForProject, Reader $geodb, Event $events) use ($oauthDefaultSuccess) {
$protocol = $request->getProtocol();
$callback = $protocol . '://' . $request->getHostname() . '/v1/account/sessions/oauth2/callback/' . $provider . '/' . $project->getId();
@ -413,27 +432,22 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect')
$appSecret = $project->getAttribute('authProviders', [])[$provider . 'Secret'] ?? '{}';
$providerEnabled = $project->getAttribute('authProviders', [])[$provider . 'Enabled'] ?? false;
if (!$providerEnabled) {
throw new Exception(Exception::PROJECT_PROVIDER_DISABLED, 'This provider is disabled. Please enable the provider from your ' . APP_NAME . ' console to continue.');
}
if (!empty($appSecret) && isset($appSecret['version'])) {
$key = App::getEnv('_APP_OPENSSL_KEY_V' . $appSecret['version']);
$appSecret = OpenSSL::decrypt($appSecret['data'], $appSecret['method'], $key, 0, \hex2bin($appSecret['iv']), \hex2bin($appSecret['tag']));
}
$className = 'Appwrite\\Auth\\OAuth2\\' . \ucfirst($provider);
if (!\class_exists($className)) {
throw new Exception(Exception::PROJECT_PROVIDER_UNSUPPORTED);
}
$providers = Config::getParam('providers');
$providerName = $providers[$provider]['name'] ?? '';
/** @var Appwrite\Auth\OAuth2 $oauth2 */
$oauth2 = new $className($appId, $appSecret, $callback);
if (!empty($state)) {
try {
$state = \array_merge($defaultState, $oauth2->parseState($state));
} catch (\Exception$exception) {
} catch (\Exception $exception) {
throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to parse login state params as passed from OAuth2 provider');
}
} else {
@ -447,27 +461,66 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect')
if (!empty($state['failure']) && !$validateURL->isValid($state['failure'])) {
throw new Exception(Exception::PROJECT_INVALID_FAILURE_URL);
}
$accessToken = $oauth2->getAccessToken($code);
$refreshToken = $oauth2->getRefreshToken($code);
$accessTokenExpiry = $oauth2->getAccessTokenExpiry($code);
if (empty($accessToken)) {
if (!empty($state['failure'])) {
$response->redirect($state['failure'], 301, 0);
$failure = [];
if (!empty($state['failure'])) {
$failure = URLParser::parse($state['failure']);
}
$failureRedirect = (function (string $type, ?string $message = null, ?int $code = null) use ($failure, $response) {
$exception = new Exception($type, $message, $code);
if (!empty($failure)) {
$query = URLParser::parseQuery($failure['query']);
$query['error'] = json_encode([
'message' => $exception->getMessage(),
'type' => $exception->getType(),
'code' => !\is_null($code) ? $code : $exception->getCode(),
]);
$failure['query'] = URLParser::unparseQuery($query);
$response->redirect(URLParser::unparse($failure), 301);
}
throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to obtain access token');
throw $exception;
});
if (!$providerEnabled) {
$failureRedirect(Exception::PROJECT_PROVIDER_DISABLED, 'This provider is disabled. Please enable the provider from your ' . APP_NAME . ' console to continue.');
}
if (!empty($error)) {
$message = 'The ' . $providerName . ' OAuth2 provider returned an error: ' . $error;
if (!empty($error_description)) {
$message .= ': ' . $error_description;
}
$failureRedirect(Exception::USER_OAUTH2_PROVIDER_ERROR, $message);
}
if (empty($code)) {
$failureRedirect(Exception::USER_OAUTH2_PROVIDER_ERROR, 'Missing OAuth2 code. Please contact the Appwrite team for additional support.');
}
if (!empty($appSecret) && isset($appSecret['version'])) {
$key = App::getEnv('_APP_OPENSSL_KEY_V' . $appSecret['version']);
$appSecret = OpenSSL::decrypt($appSecret['data'], $appSecret['method'], $key, 0, \hex2bin($appSecret['iv']), \hex2bin($appSecret['tag']));
}
$accessToken = '';
$refreshToken = '';
$accessTokenExpiry = 0;
try {
$accessToken = $oauth2->getAccessToken($code);
$refreshToken = $oauth2->getRefreshToken($code);
$accessTokenExpiry = $oauth2->getAccessTokenExpiry($code);
} catch (OAuth2Exception $ex) {
$failureRedirect(
$ex->getType(),
'Failed to obtain access token. The ' . $providerName . ' OAuth2 provider returned an error: ' . $ex->getMessage(),
$ex->getCode(),
);
}
$oauth2ID = $oauth2->getUserID($accessToken);
if (empty($oauth2ID)) {
if (!empty($state['failure'])) {
$response->redirect($state['failure'], 301, 0);
}
throw new Exception(Exception::USER_MISSING_ID);
$failureRedirect(Exception::USER_MISSING_ID);
}
$sessions = $user->getAttribute('sessions', []);
@ -515,7 +568,7 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect')
$total = $dbForProject->count('users', max: APP_LIMIT_USERS);
if ($total >= $limit) {
throw new Exception(Exception::USER_COUNT_EXCEEDED);
$failureRedirect(Exception::USER_COUNT_EXCEEDED);
}
}
@ -550,13 +603,13 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect')
]);
Authorization::skip(fn() => $dbForProject->createDocument('users', $user));
} catch (Duplicate $th) {
throw new Exception(Exception::USER_ALREADY_EXISTS);
$failureRedirect(Exception::USER_ALREADY_EXISTS);
}
}
}
if (false === $user->getAttribute('status')) { // Account is blocked
throw new Exception(Exception::USER_BLOCKED); // User is in status blocked
$failureRedirect(Exception::USER_BLOCKED); // User is in status blocked
}
// Create session token, verify user account and update OAuth2 ID and Access Token

View file

@ -2,6 +2,8 @@
namespace Appwrite\Auth;
use Appwrite\Auth\OAuth2\Exception;
abstract class OAuth2
{
/**
@ -73,6 +75,13 @@ abstract class OAuth2
*/
abstract public function refreshTokens(string $refreshToken): array;
/**
* @param string $accessToken
*
* @return string
*/
abstract public function getUserID(string $accessToken): string;
/**
* @param string $accessToken
*
@ -148,11 +157,11 @@ abstract class OAuth2
*
* @return string
*/
public function getAccessTokenExpiry(string $code): string
public function getAccessTokenExpiry(string $code): int
{
$tokens = $this->getTokens($code);
return $tokens['expires_in'] ?? '';
return $tokens['expires_in'] ?? 0;
}
// The parseState function was designed specifically for Amazon OAuth2 Adapter to override.
@ -195,8 +204,14 @@ abstract class OAuth2
// Send the request & save response to $response
$response = \curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
\curl_close($ch);
if ($code != 200) {
throw new Exception($response, $code);
}
return (string)$response;
}
}

View file

@ -0,0 +1,51 @@
<?php
namespace Appwrite\Auth\OAuth2;
use Appwrite\Extend\Exception as AppwriteException;
class Exception extends AppwriteException
{
protected string $response = '';
protected string $error = '';
protected string $errorDescription = '';
public function __construct(string $response = '', int $code = 0, \Throwable $previous = null)
{
$this->response = $response;
$this->message = $response;
$decoded = json_decode($response, true);
if (\is_array($decoded)) {
$this->error = $decoded['error'];
$this->errorDescription = $decoded['error_description'];
$this->message = $this->error . ': ' . $this->errorDescription;
}
$type = match ($code) {
400 => AppwriteException::USER_OAUTH2_BAD_REQUEST,
401 => AppwriteException::USER_OAUTH2_UNAUTHORIZED,
default => AppwriteException::USER_OAUTH2_PROVIDER_ERROR
};
parent::__construct($type, $this->message, $code, $previous);
}
/**
* Get the error parameter from the response.
*
* See https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 for more information.
*/
public function getError(): string
{
return $this->error;
}
/**
* Get the error_description parameter from the response.
*
* See https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 for more information.
*/
public function getErrorDescription(): string
{
return $this->errorDescription;
}
}

View file

@ -75,6 +75,9 @@ class Exception extends \Exception
public const USER_PHONE_ALREADY_EXISTS = 'user_phone_already_exists';
public const USER_PHONE_NOT_FOUND = 'user_phone_not_found';
public const USER_MISSING_ID = 'user_missing_id';
public const USER_OAUTH2_BAD_REQUEST = 'user_oauth2_bad_request';
public const USER_OAUTH2_UNAUTHORIZED = 'user_oauth2_unauthorized';
public const USER_OAUTH2_PROVIDER_ERROR = 'user_oauth2_provider_error';
/** Teams */
public const TEAM_NOT_FOUND = 'team_not_found';