Merge pull request #4479 from nextcloud/auth-flow

Add new auth flow basics
This commit is contained in:
Morris Jobke 2017-04-25 18:26:15 -03:00 committed by GitHub
commit 859bc245b1
10 changed files with 770 additions and 0 deletions

View File

@ -0,0 +1,238 @@
<?php
/**
* @copyright Copyright (c) 2017 Lukas Reschke <lukas@statuscode.ch>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OC\Core\Controller;
use OC\Authentication\Exceptions\InvalidTokenException;
use OC\Authentication\Exceptions\PasswordlessTokenException;
use OC\Authentication\Token\IProvider;
use OC\Authentication\Token\IToken;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Response;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\Defaults;
use OCP\IL10N;
use OCP\IRequest;
use OCP\ISession;
use OCP\IURLGenerator;
use OCP\IUserSession;
use OCP\Security\ISecureRandom;
use OCP\Session\Exceptions\SessionNotAvailableException;
class ClientFlowLoginController extends Controller {
/** @var IUserSession */
private $userSession;
/** @var IL10N */
private $l10n;
/** @var Defaults */
private $defaults;
/** @var ISession */
private $session;
/** @var IProvider */
private $tokenProvider;
/** @var ISecureRandom */
private $random;
/** @var IURLGenerator */
private $urlGenerator;
const stateName = 'client.flow.state.token';
/**
* @param string $appName
* @param IRequest $request
* @param IUserSession $userSession
* @param IL10N $l10n
* @param Defaults $defaults
* @param ISession $session
* @param IProvider $tokenProvider
* @param ISecureRandom $random
* @param IURLGenerator $urlGenerator
*/
public function __construct($appName,
IRequest $request,
IUserSession $userSession,
IL10N $l10n,
Defaults $defaults,
ISession $session,
IProvider $tokenProvider,
ISecureRandom $random,
IURLGenerator $urlGenerator) {
parent::__construct($appName, $request);
$this->userSession = $userSession;
$this->l10n = $l10n;
$this->defaults = $defaults;
$this->session = $session;
$this->tokenProvider = $tokenProvider;
$this->random = $random;
$this->urlGenerator = $urlGenerator;
}
/**
* @return string
*/
private function getClientName() {
return $this->request->getHeader('USER_AGENT') !== null ? $this->request->getHeader('USER_AGENT') : 'unknown';
}
/**
* @param string $stateToken
* @return bool
*/
private function isValidToken($stateToken) {
$currentToken = $this->session->get(self::stateName);
if(!is_string($stateToken) || !is_string($currentToken)) {
return false;
}
return hash_equals($currentToken, $stateToken);
}
/**
* @return TemplateResponse
*/
private function stateTokenForbiddenResponse() {
$response = new TemplateResponse(
$this->appName,
'403',
[
'file' => $this->l10n->t('State token does not match'),
],
'guest'
);
$response->setStatus(Http::STATUS_FORBIDDEN);
return $response;
}
/**
* @PublicPage
* @NoCSRFRequired
* @UseSession
*
* @return TemplateResponse
*/
public function showAuthPickerPage() {
if($this->userSession->isLoggedIn()) {
return new TemplateResponse(
$this->appName,
'403',
[
'file' => $this->l10n->t('Auth flow can only be started unauthenticated.'),
],
'guest'
);
}
$stateToken = $this->random->generate(
64,
ISecureRandom::CHAR_LOWER.ISecureRandom::CHAR_UPPER.ISecureRandom::CHAR_DIGITS
);
$this->session->set(self::stateName, $stateToken);
return new TemplateResponse(
$this->appName,
'loginflow/authpicker',
[
'client' => $this->getClientName(),
'instanceName' => $this->defaults->getName(),
'urlGenerator' => $this->urlGenerator,
'stateToken' => $stateToken,
'serverHost' => $this->request->getServerHost(),
],
'guest'
);
}
/**
* @NoAdminRequired
* @NoCSRFRequired
* @UseSession
*
* @param string $stateToken
* @return TemplateResponse
*/
public function redirectPage($stateToken = '') {
if(!$this->isValidToken($stateToken)) {
return $this->stateTokenForbiddenResponse();
}
return new TemplateResponse(
$this->appName,
'loginflow/redirect',
[
'urlGenerator' => $this->urlGenerator,
'stateToken' => $stateToken,
],
'empty'
);
}
/**
* @NoAdminRequired
* @UseSession
*
* @param string $stateToken
* @return Http\RedirectResponse|Response
*/
public function generateAppPassword($stateToken) {
if(!$this->isValidToken($stateToken)) {
$this->session->remove(self::stateName);
return $this->stateTokenForbiddenResponse();
}
$this->session->remove(self::stateName);
try {
$sessionId = $this->session->getId();
} catch (SessionNotAvailableException $ex) {
$response = new Response();
$response->setStatus(Http::STATUS_FORBIDDEN);
return $response;
}
try {
$sessionToken = $this->tokenProvider->getToken($sessionId);
$loginName = $sessionToken->getLoginName();
try {
$password = $this->tokenProvider->getPassword($sessionToken, $sessionId);
} catch (PasswordlessTokenException $ex) {
$password = null;
}
} catch (InvalidTokenException $ex) {
$response = new Response();
$response->setStatus(Http::STATUS_FORBIDDEN);
return $response;
}
$token = $this->random->generate(72);
$this->tokenProvider->generateToken(
$token,
$this->userSession->getUser()->getUID(),
$loginName,
$password,
$this->getClientName(),
IToken::PERMANENT_TOKEN,
IToken::DO_NOT_REMEMBER
);
return new Http\RedirectResponse('nc://' . urlencode($loginName) . ':' . urlencode($token) . '@' . $this->request->getServerHost());
}
}

View File

@ -0,0 +1,9 @@
.picker-window {
display: block;
padding: 10px;
margin-bottom: 20px;
background-color: rgba(0,0,0,.3);
color: #fff;
border-radius: 3px;
cursor: default;
}

View File

@ -0,0 +1,13 @@
jQuery(document).ready(function() {
$('#app-token-login').click(function (e) {
e.preventDefault();
$(this).addClass('hidden');
$('#redirect-link').addClass('hidden');
$('#app-token-login-field').removeClass('hidden');
});
$('#submit-app-token-login').click(function(e) {
e.preventDefault();
window.location.href = 'nc://' + encodeURIComponent($('#user').val()) + ':' + encodeURIComponent($('#password').val()) + '@' + encodeURIComponent($('#serverHost').val());
});
});

View File

@ -0,0 +1,3 @@
jQuery(document).ready(function() {
$('#submit-redirect-form').trigger('click');
});

View File

@ -49,6 +49,9 @@ $application->registerRoutes($this, [
['name' => 'login#confirmPassword', 'url' => '/login/confirm', 'verb' => 'POST'],
['name' => 'login#showLoginForm', 'url' => '/login', 'verb' => 'GET'],
['name' => 'login#logout', 'url' => '/logout', 'verb' => 'GET'],
['name' => 'ClientFlowLogin#showAuthPickerPage', 'url' => '/login/flow', 'verb' => 'GET'],
['name' => 'ClientFlowLogin#redirectPage', 'url' => '/login/flow/redirect', 'verb' => 'GET'],
['name' => 'ClientFlowLogin#generateAppPassword', 'url' => '/login/flow', 'verb' => 'POST'],
['name' => 'TwoFactorChallenge#selectChallenge', 'url' => '/login/selectchallenge', 'verb' => 'GET'],
['name' => 'TwoFactorChallenge#showChallenge', 'url' => '/login/challenge/{challengeProviderId}', 'verb' => 'GET'],
['name' => 'TwoFactorChallenge#solveChallenge', 'url' => '/login/challenge/{challengeProviderId}', 'verb' => 'POST'],

View File

@ -0,0 +1,57 @@
<?php
/**
* @copyright Copyright (c) 2017 Lukas Reschke <lukas@statuscode.ch>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
script('core', 'login/authpicker');
style('core', 'login/authpicker');
/** @var array $_ */
/** @var \OCP\IURLGenerator $urlGenerator */
$urlGenerator = $_['urlGenerator'];
?>
<div class="picker-window">
<p class="info">
<?php p($l->t('You are about to grant "%s" access to your %s account.', [$_['client'], $_['instanceName']])) ?>
</p>
<br/>
<p id="redirect-link">
<a href="<?php p($urlGenerator->linkToRouteAbsolute('core.ClientFlowLogin.redirectPage', ['stateToken' => $_['stateToken']])) ?>">
<input type="submit" class="login primary icon-confirm-white" value="<?php p('Grant access') ?>">
</a>
</p>
<fieldset id="app-token-login-field" class="hidden">
<p class="grouptop">
<input type="text" name="user" id="user" placeholder="<?php p($l->t('Username')) ?>">
<label for="user" class="infield"><?php p($l->t('Username')) ?></label>
</p>
<p class="groupbottom">
<input type="password" name="password" id="password" placeholder="<?php p($l->t('App token')) ?>">
<label for="password" class="infield"><?php p($l->t('Password')) ?></label>
</p>
<input type="hidden" id="serverHost" value="<?php p($_['serverHost']) ?>" />
<input id="submit-app-token-login" type="submit" class="login primary icon-confirm-white" value="<?php p('Grant access') ?>">
</fieldset>
</div>
<a id="app-token-login" class="warning" href="#"><?php p($l->t('Alternative login using app token')) ?></a>

View File

@ -0,0 +1,37 @@
<?php
/**
* @copyright Copyright (c) 2017 Lukas Reschke <lukas@statuscode.ch>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
script('core', 'login/redirect');
style('core', 'login/authpicker');
/** @var array $_ */
/** @var \OCP\IURLGenerator $urlGenerator */
$urlGenerator = $_['urlGenerator'];
?>
<div class="picker-window">
<p class="info"><?php p($l->t('Redirecting …')) ?></p>
</div>
<form method="POST" action="<?php p($urlGenerator->linkToRouteAbsolute('core.ClientFlowLogin.generateAppPassword')) ?>">
<input type="hidden" name="requesttoken" value="<?php p($_['requesttoken']) ?>" />
<input type="hidden" name="stateToken" value="<?php p($_['stateToken']) ?>" />
<input id="submit-redirect-form" type="submit" class="hidden "/>
</form>

View File

@ -444,6 +444,7 @@ return array(
'OC\\Core\\Command\\User\\ResetPassword' => $baseDir . '/core/Command/User/ResetPassword.php',
'OC\\Core\\Command\\User\\Setting' => $baseDir . '/core/Command/User/Setting.php',
'OC\\Core\\Controller\\AvatarController' => $baseDir . '/core/Controller/AvatarController.php',
'OC\\Core\\Controller\\ClientFlowLoginController' => $baseDir . '/core/Controller/ClientFlowLoginController.php',
'OC\\Core\\Controller\\CssController' => $baseDir . '/core/Controller/CssController.php',
'OC\\Core\\Controller\\JsController' => $baseDir . '/core/Controller/JsController.php',
'OC\\Core\\Controller\\LoginController' => $baseDir . '/core/Controller/LoginController.php',

View File

@ -474,6 +474,7 @@ class ComposerStaticInit53792487c5a8370acc0b06b1a864ff4c
'OC\\Core\\Command\\User\\ResetPassword' => __DIR__ . '/../../..' . '/core/Command/User/ResetPassword.php',
'OC\\Core\\Command\\User\\Setting' => __DIR__ . '/../../..' . '/core/Command/User/Setting.php',
'OC\\Core\\Controller\\AvatarController' => __DIR__ . '/../../..' . '/core/Controller/AvatarController.php',
'OC\\Core\\Controller\\ClientFlowLoginController' => __DIR__ . '/../../..' . '/core/Controller/ClientFlowLoginController.php',
'OC\\Core\\Controller\\CssController' => __DIR__ . '/../../..' . '/core/Controller/CssController.php',
'OC\\Core\\Controller\\JsController' => __DIR__ . '/../../..' . '/core/Controller/JsController.php',
'OC\\Core\\Controller\\LoginController' => __DIR__ . '/../../..' . '/core/Controller/LoginController.php',

View File

@ -0,0 +1,408 @@
<?php
/**
* @copyright Copyright (c) 2017 Lukas Reschke <lukas@statuscode.ch>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace Tests\Core\Controller;
use OC\Authentication\Exceptions\InvalidTokenException;
use OC\Authentication\Exceptions\PasswordlessTokenException;
use OC\Authentication\Token\IProvider;
use OC\Authentication\Token\IToken;
use OC\Core\Controller\ClientFlowLoginController;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\Defaults;
use OCP\IL10N;
use OCP\IRequest;
use OCP\ISession;
use OCP\IURLGenerator;
use OCP\IUser;
use OCP\IUserSession;
use OCP\Security\ISecureRandom;
use OCP\Session\Exceptions\SessionNotAvailableException;
use Test\TestCase;
class ClientFlowLoginControllerTest extends TestCase {
/** @var IRequest|\PHPUnit_Framework_MockObject_MockObject */
private $request;
/** @var IUserSession|\PHPUnit_Framework_MockObject_MockObject */
private $userSession;
/** @var IL10N|\PHPUnit_Framework_MockObject_MockObject */
private $l10n;
/** @var Defaults|\PHPUnit_Framework_MockObject_MockObject */
private $defaults;
/** @var ISession|\PHPUnit_Framework_MockObject_MockObject */
private $session;
/** @var IProvider|\PHPUnit_Framework_MockObject_MockObject */
private $tokenProvider;
/** @var ISecureRandom|\PHPUnit_Framework_MockObject_MockObject */
private $random;
/** @var IURLGenerator|\PHPUnit_Framework_MockObject_MockObject */
private $urlGenerator;
/** @var ClientFlowLoginController */
private $clientFlowLoginController;
public function setUp() {
parent::setUp();
$this->request = $this->createMock(IRequest::class);
$this->userSession = $this->createMock(IUserSession::class);
$this->l10n = $this->createMock(IL10N::class);
$this->l10n
->expects($this->any())
->method('t')
->will($this->returnCallback(function($text, $parameters = array()) {
return vsprintf($text, $parameters);
}));
$this->defaults = $this->createMock(Defaults::class);
$this->session = $this->createMock(ISession::class);
$this->tokenProvider = $this->createMock(IProvider::class);
$this->random = $this->createMock(ISecureRandom::class);
$this->urlGenerator = $this->createMock(IURLGenerator::class);
$this->clientFlowLoginController = new ClientFlowLoginController(
'core',
$this->request,
$this->userSession,
$this->l10n,
$this->defaults,
$this->session,
$this->tokenProvider,
$this->random,
$this->urlGenerator
);
}
public function testShowAuthPickerPageNotAuthenticated() {
$this->userSession
->expects($this->once())
->method('isLoggedIn')
->willReturn(true);
$expected = new TemplateResponse(
'core',
'403',
[
'file' => 'Auth flow can only be started unauthenticated.',
],
'guest'
);
$this->assertEquals($expected, $this->clientFlowLoginController->showAuthPickerPage());
}
public function testShowAuthPickerPage() {
$this->userSession
->expects($this->once())
->method('isLoggedIn')
->willReturn(false);
$this->random
->expects($this->once())
->method('generate')
->with(
64,
ISecureRandom::CHAR_LOWER.ISecureRandom::CHAR_UPPER.ISecureRandom::CHAR_DIGITS
)
->willReturn('StateToken');
$this->session
->expects($this->once())
->method('set')
->with('client.flow.state.token', 'StateToken');
$this->request
->expects($this->exactly(2))
->method('getHeader')
->with('USER_AGENT')
->willReturn('Mac OS X Sync Client');
$this->defaults
->expects($this->once())
->method('getName')
->willReturn('ExampleCloud');
$this->request
->expects($this->once())
->method('getServerHost')
->willReturn('example.com');
$expected = new TemplateResponse(
'core',
'loginflow/authpicker',
[
'client' => 'Mac OS X Sync Client',
'instanceName' => 'ExampleCloud',
'urlGenerator' => $this->urlGenerator,
'stateToken' => 'StateToken',
'serverHost' => 'example.com',
],
'guest'
);
$this->assertEquals($expected, $this->clientFlowLoginController->showAuthPickerPage());
}
public function testRedirectPageWithInvalidToken() {
$this->session
->expects($this->once())
->method('get')
->with('client.flow.state.token')
->willReturn('OtherToken');
$expected = new TemplateResponse(
'core',
'403',
[
'file' => 'State token does not match',
],
'guest'
);
$expected->setStatus(Http::STATUS_FORBIDDEN);
$this->assertEquals($expected, $this->clientFlowLoginController->redirectPage('MyStateToken'));
}
public function testRedirectPageWithoutToken() {
$this->session
->expects($this->once())
->method('get')
->with('client.flow.state.token')
->willReturn(null);
$expected = new TemplateResponse(
'core',
'403',
[
'file' => 'State token does not match',
],
'guest'
);
$expected->setStatus(Http::STATUS_FORBIDDEN);
$this->assertEquals($expected, $this->clientFlowLoginController->redirectPage('MyStateToken'));
}
public function testRedirectPage() {
$this->session
->expects($this->once())
->method('get')
->with('client.flow.state.token')
->willReturn('MyStateToken');
$expected = new TemplateResponse(
'core',
'loginflow/redirect',
[
'urlGenerator' => $this->urlGenerator,
'stateToken' => 'MyStateToken',
],
'empty'
);
$this->assertEquals($expected, $this->clientFlowLoginController->redirectPage('MyStateToken'));
}
public function testGenerateAppPasswordWithInvalidToken() {
$this->session
->expects($this->once())
->method('get')
->with('client.flow.state.token')
->willReturn('OtherToken');
$this->session
->expects($this->once())
->method('remove')
->with('client.flow.state.token');
$expected = new TemplateResponse(
'core',
'403',
[
'file' => 'State token does not match',
],
'guest'
);
$expected->setStatus(Http::STATUS_FORBIDDEN);
$this->assertEquals($expected, $this->clientFlowLoginController->generateAppPassword('MyStateToken'));
}
public function testGenerateAppPasswordWithSessionNotAvailableException() {
$this->session
->expects($this->once())
->method('get')
->with('client.flow.state.token')
->willReturn('MyStateToken');
$this->session
->expects($this->once())
->method('remove')
->with('client.flow.state.token');
$this->session
->expects($this->once())
->method('getId')
->willThrowException(new SessionNotAvailableException());
$expected = new Http\Response();
$expected->setStatus(Http::STATUS_FORBIDDEN);
$this->assertEquals($expected, $this->clientFlowLoginController->generateAppPassword('MyStateToken'));
}
public function testGenerateAppPasswordWithInvalidTokenException() {
$this->session
->expects($this->once())
->method('get')
->with('client.flow.state.token')
->willReturn('MyStateToken');
$this->session
->expects($this->once())
->method('remove')
->with('client.flow.state.token');
$this->session
->expects($this->once())
->method('getId')
->willReturn('SessionId');
$this->tokenProvider
->expects($this->once())
->method('getToken')
->with('SessionId')
->willThrowException(new InvalidTokenException());
$expected = new Http\Response();
$expected->setStatus(Http::STATUS_FORBIDDEN);
$this->assertEquals($expected, $this->clientFlowLoginController->generateAppPassword('MyStateToken'));
}
public function testGeneratePasswordWithPassword() {
$this->session
->expects($this->once())
->method('get')
->with('client.flow.state.token')
->willReturn('MyStateToken');
$this->session
->expects($this->once())
->method('remove')
->with('client.flow.state.token');
$this->session
->expects($this->once())
->method('getId')
->willReturn('SessionId');
$myToken = $this->createMock(IToken::class);
$myToken
->expects($this->once())
->method('getLoginName')
->willReturn('MyLoginName');
$this->tokenProvider
->expects($this->once())
->method('getToken')
->with('SessionId')
->willReturn($myToken);
$this->tokenProvider
->expects($this->once())
->method('getPassword')
->with($myToken, 'SessionId')
->willReturn('MyPassword');
$this->random
->expects($this->once())
->method('generate')
->with(72)
->willReturn('MyGeneratedToken');
$user = $this->createMock(IUser::class);
$user
->expects($this->once())
->method('getUID')
->willReturn('MyUid');
$this->userSession
->expects($this->once())
->method('getUser')
->willReturn($user);
$this->tokenProvider
->expects($this->once())
->method('generateToken')
->with(
'MyGeneratedToken',
'MyUid',
'MyLoginName',
'MyPassword',
'unknown',
IToken::PERMANENT_TOKEN,
IToken::DO_NOT_REMEMBER
);
$this->request
->expects($this->once())
->method('getServerHost')
->willReturn('example.com');
$expected = new Http\RedirectResponse('nc://MyLoginName:MyGeneratedToken@example.com');
$this->assertEquals($expected, $this->clientFlowLoginController->generateAppPassword('MyStateToken'));
}
public function testGeneratePasswordWithoutPassword() {
$this->session
->expects($this->once())
->method('get')
->with('client.flow.state.token')
->willReturn('MyStateToken');
$this->session
->expects($this->once())
->method('remove')
->with('client.flow.state.token');
$this->session
->expects($this->once())
->method('getId')
->willReturn('SessionId');
$myToken = $this->createMock(IToken::class);
$myToken
->expects($this->once())
->method('getLoginName')
->willReturn('MyLoginName');
$this->tokenProvider
->expects($this->once())
->method('getToken')
->with('SessionId')
->willReturn($myToken);
$this->tokenProvider
->expects($this->once())
->method('getPassword')
->with($myToken, 'SessionId')
->willThrowException(new PasswordlessTokenException());
$this->random
->expects($this->once())
->method('generate')
->with(72)
->willReturn('MyGeneratedToken');
$user = $this->createMock(IUser::class);
$user
->expects($this->once())
->method('getUID')
->willReturn('MyUid');
$this->userSession
->expects($this->once())
->method('getUser')
->willReturn($user);
$this->tokenProvider
->expects($this->once())
->method('generateToken')
->with(
'MyGeneratedToken',
'MyUid',
'MyLoginName',
null,
'unknown',
IToken::PERMANENT_TOKEN,
IToken::DO_NOT_REMEMBER
);
$this->request
->expects($this->once())
->method('getServerHost')
->willReturn('example.com');
$expected = new Http\RedirectResponse('nc://MyLoginName:MyGeneratedToken@example.com');
$this->assertEquals($expected, $this->clientFlowLoginController->generateAppPassword('MyStateToken'));
}
}