diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 6190cec905..6504052f9a 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -2336,7 +2336,8 @@ App::post('/v1/account/tokens/phone') ; }); -App::post('/v1/account/jwt') +App::post('/v1/account/jwts') + ->alias('/v1/account/jwt') ->desc('Create JWT') ->groups(['api', 'account', 'auth']) ->label('scope', 'account') @@ -2369,15 +2370,11 @@ App::post('/v1/account/jwt') throw new Exception(Exception::USER_SESSION_NOT_FOUND); } - $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 900, 10); // Instantiate with key, algo, maxAge and leeway. + $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 900, 0); $response ->setStatusCode(Response::STATUS_CODE_CREATED) ->dynamic(new Document(['jwt' => $jwt->encode([ - // 'uid' => 1, - // 'aud' => 'http://site.com', - // 'scopes' => ['user'], - // 'iss' => 'http://api.mysite.com', 'userId' => $user->getId(), 'sessionId' => $current->getId(), ])]), Response::MODEL_JWT); diff --git a/app/controllers/api/functions.php b/app/controllers/api/functions.php index 670ca2f99e..52b68ab156 100644 --- a/app/controllers/api/functions.php +++ b/app/controllers/api/functions.php @@ -1585,7 +1585,8 @@ App::post('/v1/functions/:functionId/executions') } if (!$current->isEmpty()) { - $jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 900, 10); // Instantiate with key, algo, maxAge and leeway. + $jwtExpiry = $function->getAttribute('timeout', 900); + $jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $jwtExpiry, 0); $jwt = $jwtObj->encode([ 'userId' => $user->getId(), 'sessionId' => $current->getId(), @@ -1594,7 +1595,7 @@ App::post('/v1/functions/:functionId/executions') } $jwtExpiry = $function->getAttribute('timeout', 900); - $jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $jwtExpiry, 10); + $jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $jwtExpiry, 0); $apiKey = $jwtObj->encode([ 'projectId' => $project->getId(), 'scopes' => $function->getAttribute('scopes', []) diff --git a/app/controllers/api/messaging.php b/app/controllers/api/messaging.php index ceb2d2aca5..e3696cc7e0 100644 --- a/app/controllers/api/messaging.php +++ b/app/controllers/api/messaging.php @@ -2939,11 +2939,9 @@ App::post('/v1/messaging/messages/push') $expiry = (new \DateTime())->add(new \DateInterval('P15D'))->format('U'); } - $encoder = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1')); + $encoder = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', \intval($expiry), 0); $jwt = $encoder->encode([ - 'iat' => \time(), - 'exp' => $expiry, 'bucketId' => $bucket->getId(), 'fileId' => $file->getId(), 'projectId' => $project->getId(), @@ -3801,11 +3799,9 @@ App::patch('/v1/messaging/messages/push/:messageId') $expiry = (new \DateTime())->add(new \DateInterval('P15D'))->format('U'); } - $encoder = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1')); + $encoder = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', \intval($expiry), 0); $jwt = $encoder->encode([ - 'iat' => \time(), - 'exp' => $expiry, 'bucketId' => $bucket->getId(), 'fileId' => $file->getId(), 'projectId' => $project->getId(), diff --git a/app/controllers/api/storage.php b/app/controllers/api/storage.php index f7334e3ebb..8ee7386d98 100644 --- a/app/controllers/api/storage.php +++ b/app/controllers/api/storage.php @@ -1328,7 +1328,7 @@ App::get('/v1/storage/buckets/:bucketId/files/:fileId/push') ->action(function (string $bucketId, string $fileId, string $jwt, Response $response, Request $request, Database $dbForProject, Document $project, string $mode, Device $deviceForFiles) { $bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); - $decoder = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1')); + $decoder = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 3600, 0); try { $decoded = $decoder->decode($jwt); @@ -1339,8 +1339,7 @@ App::get('/v1/storage/buckets/:bucketId/files/:fileId/push') if ( $decoded['projectId'] !== $project->getId() || $decoded['bucketId'] !== $bucketId || - $decoded['fileId'] !== $fileId || - $decoded['exp'] < \time() + $decoded['fileId'] !== $fileId ) { throw new Exception(Exception::USER_UNAUTHORIZED); } diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index abfcb34a5a..5d86beaf82 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -1,5 +1,6 @@ noContent(); }); +App::post('/v1/users/:userId/jwts') + ->desc('Create user JWT') + ->groups(['api', 'users']) + ->label('scope', 'users.write') + ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) + ->label('sdk.namespace', 'users') + ->label('sdk.method', 'createJWT') + ->label('sdk.description', '/docs/references/users/create-user-jwt.md') + ->label('sdk.response.code', Response::STATUS_CODE_CREATED) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_JWT) + ->param('userId', '', new UID(), 'User ID.') + ->param('sessionId', 'recent', new UID(), 'Session ID. Use the string \'recent\' to use the most recent session. Defaults to the most recent session.', true) + ->param('duration', 900, new Range(0, 3600), 'Time in seconds before JWT expires. Default duration is 900 seconds, and maximum is 3600 seconds.', true) + ->inject('response') + ->inject('dbForProject') + ->action(function (string $userId, string $sessionId, int $duration, Response $response, Database $dbForProject) { + + $user = $dbForProject->getDocument('users', $userId); + + if ($user->isEmpty()) { + throw new Exception(Exception::USER_NOT_FOUND); + } + + $sessions = $user->getAttribute('sessions', []); + $session = new Document(); + + if($sessionId === 'recent') { + // Get most recent + $session = \count($sessions) > 0 ? $sessions[\count($sessions) - 1] : new Document(); + } else { + // Find by ID + foreach ($sessions as $loopSession) { /** @var Utopia\Database\Document $loopSession */ + if ($loopSession->getId() == $sessionId) { + $session = $loopSession; + break; + } + } + } + + if ($session->isEmpty()) { + throw new Exception(Exception::USER_SESSION_NOT_FOUND); + } + + $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $duration, 0); + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic(new Document(['jwt' => $jwt->encode([ + 'userId' => $user->getId(), + 'sessionId' => $session->getId() + ])]), Response::MODEL_JWT); + }); + App::get('/v1/users/usage') ->desc('Get users usage stats') ->groups(['api', 'users']) diff --git a/docs/references/users/create-user-jwt.md b/docs/references/users/create-user-jwt.md new file mode 100644 index 0000000000..5e8c26c8f7 --- /dev/null +++ b/docs/references/users/create-user-jwt.md @@ -0,0 +1 @@ +Use this endpoint to create a JSON Web Token for user by its unique ID. You can use the resulting JWT to authenticate on behalf of the user. The JWT secret will become invalid if the session it uses gets deleted. \ No newline at end of file diff --git a/src/Appwrite/Platform/Workers/Functions.php b/src/Appwrite/Platform/Workers/Functions.php index fc9a1242ac..cbba9657ad 100644 --- a/src/Appwrite/Platform/Workers/Functions.php +++ b/src/Appwrite/Platform/Workers/Functions.php @@ -284,7 +284,7 @@ class Functions extends Action $runtime = $runtimes[$function->getAttribute('runtime')]; $jwtExpiry = $function->getAttribute('timeout', 900); - $jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $jwtExpiry, 10); + $jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $jwtExpiry, 0); $apiKey = $jwtObj->encode([ 'projectId' => $project->getId(), 'scopes' => $function->getAttribute('scopes', []) diff --git a/tests/e2e/Services/Users/UsersBase.php b/tests/e2e/Services/Users/UsersBase.php index 1737e0483d..50f2578800 100644 --- a/tests/e2e/Services/Users/UsersBase.php +++ b/tests/e2e/Services/Users/UsersBase.php @@ -1553,6 +1553,137 @@ trait UsersBase return $data; } + public function testUserJWT() + { + // Create user + $userId = ID::unique(); + $user = $this->client->call(Client::METHOD_POST, '/users', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'userId' => $userId, + 'email' => 'jwtuser@appwrite.io', + 'password' => 'password', + ], false); + $this->assertEquals($user['headers']['status-code'], 201); + + // Create two sessions + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]), [ + 'email' => 'jwtuser@appwrite.io', + 'password' => 'password', + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertEquals($userId, $response['body']['userId']); + $this->assertNotEmpty($response['body']['$id']); + $session1Id = $response['body']['$id']; + + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]), [ + 'email' => 'jwtuser@appwrite.io', + 'password' => 'password', + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertEquals($userId, $response['body']['userId']); + $this->assertNotEmpty($response['body']['$id']); + $session2Id = $response['body']['$id']; + + // Create JWT 1 for older session by ID + $response = $this->client->call(Client::METHOD_POST, '/users/' . $userId . '/jwts', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'sessionId' => $session1Id + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['jwt']); + $jwt1 = $response['body']['jwt']; + + // Ensure JWT 1 works + $response = $this->client->call(Client::METHOD_GET, '/account', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-jwt' => $jwt1, + ])); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals($userId, $response['body']['$id']); + + // Create JWT 2 for latest session using default param + $response = $this->client->call(Client::METHOD_POST, '/users/' . $userId . '/jwts', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'duration' => 5 + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['jwt']); + $jwt2 = $response['body']['jwt']; + + // Ensure JWT 2 works + $response = $this->client->call(Client::METHOD_GET, '/account', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-jwt' => $jwt2, + ])); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals($userId, $response['body']['$id']); + + // Wait, ensure JWT 2 no longer works because of short duration + + \sleep(10); + $response = $this->client->call(Client::METHOD_GET, '/account', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-jwt' => $jwt2, + ])); + + $this->assertEquals(401, $response['headers']['status-code']); + + // Delete session, ensure JWT 1 no longer works because of session missing + + $response = $this->client->call(Client::METHOD_DELETE, '/users/' . $userId . '/sessions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'sessionId' => $session1Id + ]); + + $this->assertEquals(204, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_GET, '/account', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-jwt' => $jwt1, + ])); + + $this->assertEquals(401, $response['headers']['status-code']); + + // Cleanup after test + + $response = $this->client->call(Client::METHOD_DELETE, '/users/' . $userId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals($response['headers']['status-code'], 204); + } + // TODO add test for session delete // TODO add test for all sessions delete }