From e73aa2605563a7141913fc3dceaba7aa4404b7a2 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sun, 19 Oct 2025 01:09:40 +0530 Subject: [PATCH] feat: browser based cli login --- app/config/auth.php | 7 ++ app/config/collections/common.php | 22 +++++ app/config/errors.php | 5 + app/controllers/api/account.php | 140 ++++++++++++++++++++++++++++ app/controllers/shared/api/auth.php | 6 ++ app/init/constants.php | 3 + src/Appwrite/Extend/Exception.php | 1 + 7 files changed, 184 insertions(+) diff --git a/app/config/auth.php b/app/config/auth.php index 2330fe75cf..94b69194b2 100644 --- a/app/config/auth.php +++ b/app/config/auth.php @@ -52,4 +52,11 @@ return [ 'docs' => 'https://appwrite.io/docs/references/cloud/client-web/account#accountCreatePhoneToken', 'enabled' => true, ], + 'cli' => [ + 'name' => 'CLI', + 'key' => 'cli', + 'icon' => '/images/users/cli.png', + 'docs' => 'https://appwrite.io/docs/references/cloud/client-web/account#accountCreateCLIToken', + 'enabled' => true, + ], ]; diff --git a/app/config/collections/common.php b/app/config/collections/common.php index 804929fcfd..69aea7b36d 100644 --- a/app/config/collections/common.php +++ b/app/config/collections/common.php @@ -520,6 +520,28 @@ return [ 'default' => null, 'array' => false, 'filters' => [], + ], + [ + '$id' => ID::custom('publicKey'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 2048, + 'signed' => false, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('name'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 256, + 'signed' => false, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], ] ], 'indexes' => [ diff --git a/app/config/errors.php b/app/config/errors.php index 2e18f05797..1d153cae86 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -171,6 +171,11 @@ return [ 'description' => 'Invalid token passed in the request.', 'code' => 401, ], + Exception::USER_TOKEN_ALREADY_EXISTS => [ + 'name' => Exception::USER_TOKEN_ALREADY_EXISTS, + 'description' => 'A token with the same ID already exists.', + 'code' => 409, + ], Exception::USER_PASSWORD_RESET_REQUIRED => [ 'name' => Exception::USER_PASSWORD_RESET_REQUIRED, 'description' => 'The current user requires a password reset.', diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 418770fc9c..ea992c37e8 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -200,6 +200,7 @@ $createSession = function (string $userId, string $secret, Request $request, Res $factor = (match ($verifiedToken->getAttribute('type')) { TOKEN_TYPE_MAGIC_URL, TOKEN_TYPE_OAUTH2, + TOKEN_TYPE_CLI, TOKEN_TYPE_EMAIL => Type::EMAIL, TOKEN_TYPE_PHONE => Type::PHONE, TOKEN_TYPE_GENERIC => 'token', @@ -2784,6 +2785,145 @@ App::post('/v1/account/tokens/phone') ->dynamic($token, Response::MODEL_TOKEN); }); +App::post('/v1/account/tokens/cli') + ->desc('Create CLI token') + ->groups(['api', 'account', 'auth']) + ->label('scope', 'sessions.write') + ->label('auth.type', 'cli') + ->label('audits.event', 'session.create') + ->label('audits.resource', 'user/{response.userId}') + ->label('sdk', new Method( + namespace: 'account', + group: 'tokens', + name: 'createCLIToken', + description: '/docs/references/account/create-token-cli.md', + auth: [AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_CREATED, + model: Response::MODEL_TOKEN, + ) + ], + contentType: ContentType::JSON, + )) + ->label('abuse-limit', 10) + ->label('abuse-key', 'url:{url},userId:{userId}') + ->param('publicKey', '', new Text(2048), 'Public key associated with the CLI token. Used for cryptographic authentication.', false) + ->param('name', '', new Text(256), 'A descriptive name for the CLI token to help identify its purpose or usage.', true) + ->inject('request') + ->inject('response') + ->inject('user') + ->inject('dbForProject') + ->inject('proofForCode') + ->action(function (string $publicKey, string $name, Request $request, Response $response, Document $user, Database $dbForProject, ProofsCode $proofForCode) { + if ($user->isEmpty()) { + throw new Exception(Exception::USER_UNAUTHORIZED); + } + + $secret = strtoupper(substr($proofForCode->generate(), 0, 6)); + $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), TOKEN_EXPIRATION_CLI)); + + $token = new Document([ + '$id' => ID::unique(), + 'userId' => $user->getId(), + 'userInternalId' => $user->getSequence(), + 'type' => TOKEN_TYPE_CLI, + 'secret' => $proofForCode->hash($secret), + 'expire' => $expire, + 'userAgent' => $request->getUserAgent('UNKNOWN'), + 'ip' => $request->getIP(), + 'publicKey' => $publicKey, + 'name' => $name, + ]); + + Authorization::setRole(Role::user($user->getId())->toString()); + + try { + $token = $dbForProject->createDocument('tokens', $token + ->setAttribute('$permissions', [ + Permission::read(Role::user($user->getId())), + Permission::update(Role::user($user->getId())), + Permission::delete(Role::user($user->getId())), + ])); + } catch (Duplicate) { + throw new Exception(Exception::USER_TOKEN_ALREADY_EXISTS); + } + + $dbForProject->purgeCachedDocument('users', $user->getId()); + $token->setAttribute('secret', $secret); + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->json([ + '$id' => $token->getId(), + '$createdAt' => DateTime::formatTz($token->getAttribute('$createdAt')), + 'userId' => $token->getAttribute('userId'), + 'secret' => $secret, + 'expire' => DateTime::formatTz($token->getAttribute('expire')), + ]); + }); + +App::post('/v1/account/sessions/cli') + ->alias('/v1/account/sessions') + ->desc('Create CLI session') + ->groups(['api', 'account', 'auth', 'session']) + ->label('event', 'users.[userId].sessions.[sessionId].create') + ->label('scope', 'sessions.write') + ->label('auth.type', 'cli') + ->label('audits.event', 'session.create') + ->label('audits.resource', 'user/{response.userId}') + ->label('audits.userId', '{response.userId}') + ->label('sdk', new Method( + namespace: 'account', + group: 'sessions', + name: 'createCLISession', + description: '/docs/references/account/create-session-cli.md', + auth: [], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_CREATED, + model: Response::MODEL_SESSION, + ) + ], + contentType: ContentType::JSON + )) + ->label('abuse-limit', 10) + ->label('abuse-key', 'url:{url},userId:{param-userId}') + ->param('userId', '', new CustomId(), 'User ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.') + ->param('secret', '', new Text(256), 'Secret of a CLI token generated by login methods. For example, the `createCLIToken` method.') + ->inject('request') + ->inject('response') + ->inject('user') + ->inject('dbForProject') + ->inject('project') + ->inject('locale') + ->inject('geodb') + ->inject('queueForEvents') + ->inject('queueForMails') + ->inject('hooks') + ->inject('store') + ->inject('proofForPassword') + ->inject('proofForToken') + ->action(function (string $userId, string $secret, Request $request, Response $response, Document $user, Database $dbForProject, Document $project, Locale $locale, Reader $geodb, Event $queueForEvents, Mail $queueForMails, Hooks $hooks, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken) { + /** @var Utopia\Database\Document $user */ + $userFromRequest = Authorization::skip(fn () => $dbForProject->getDocument('users', $userId)); + + if ($userFromRequest->isEmpty()) { + throw new Exception(Exception::USER_INVALID_TOKEN); + } + + $verifiedToken = Auth::tokenVerify($userFromRequest->getAttribute('tokens', []), null, $secret, $proofForToken); + if (!$verifiedToken) { + throw new Exception(Exception::USER_INVALID_TOKEN); + } + + $user->setAttributes($userFromRequest->getArrayCopy()); + + // work in progress + // idea is to create a session, create a JWT and encrypt it using the the public and send it to the client + // for providerUid we will use token name, for eg. cli_chiragaggarwal@Chirags-MacBook-Pro.local_1760805986 + }); + App::post('/v1/account/jwts') ->alias('/v1/account/jwt') ->desc('Create JWT') diff --git a/app/controllers/shared/api/auth.php b/app/controllers/shared/api/auth.php index 8f5e981362..727bc4a085 100644 --- a/app/controllers/shared/api/auth.php +++ b/app/controllers/shared/api/auth.php @@ -100,6 +100,12 @@ App::init() } break; + case 'cli': + if (($auths[Config::getParam('auth')['cli']['key']] ?? true) === false) { + throw new Exception(Exception::USER_AUTH_METHOD_UNSUPPORTED, 'CLI authentication is disabled for this project'); + } + break; + default: throw new Exception(Exception::USER_AUTH_METHOD_UNSUPPORTED, 'Unsupported authentication route'); } diff --git a/app/init/constants.php b/app/init/constants.php index aaa3e1e206..d6c0aa625c 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -111,6 +111,7 @@ const TOKEN_EXPIRATION_RECOVERY = 3600; /* 1 hour */ const TOKEN_EXPIRATION_CONFIRM = 3600 * 1; /* 1 hour */ const TOKEN_EXPIRATION_OTP = 60 * 15; /* 15 minutes */ const TOKEN_EXPIRATION_GENERIC = 60 * 15; /* 15 minutes */ +const TOKEN_EXPIRATION_CLI = 60 * 15; /* 15 minutes */ /** * Token Lengths. @@ -133,6 +134,7 @@ const TOKEN_TYPE_PHONE = 6; const TOKEN_TYPE_OAUTH2 = 7; const TOKEN_TYPE_GENERIC = 8; const TOKEN_TYPE_EMAIL = 9; // OTP +const TOKEN_TYPE_CLI = 10; /** * Session Providers. @@ -144,6 +146,7 @@ const SESSION_PROVIDER_PHONE = 'phone'; const SESSION_PROVIDER_OAUTH2 = 'oauth2'; const SESSION_PROVIDER_TOKEN = 'token'; const SESSION_PROVIDER_SERVER = 'server'; +const SESSION_PROVIDER_CLI = 'cli'; /** * Activity associated with user or the app. diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php index 6f8744568a..c988a504bb 100644 --- a/src/Appwrite/Extend/Exception.php +++ b/src/Appwrite/Extend/Exception.php @@ -70,6 +70,7 @@ class Exception extends \Exception public const string USER_ALREADY_EXISTS = 'user_already_exists'; public const string USER_BLOCKED = 'user_blocked'; public const string USER_INVALID_TOKEN = 'user_invalid_token'; + public const string USER_TOKEN_ALREADY_EXISTS = 'user_token_already_exists'; public const string USER_PASSWORD_RESET_REQUIRED = 'user_password_reset_required'; public const string USER_EMAIL_NOT_WHITELISTED = 'user_email_not_whitelisted'; public const string USER_IP_NOT_WHITELISTED = 'user_ip_not_whitelisted';