Skip to content

Commit 1367034

Browse files
authored
Merge pull request #62687 from nextcloud/carl/gss-oauth
feat(oauth2): Forward app tokens from primary to secondary in global scale
2 parents 8614ca4 + 076a7a6 commit 1367034

14 files changed

Lines changed: 633 additions & 69 deletions

File tree

apps/oauth2/appinfo/routes.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,5 +28,11 @@
2828
'url' => '/api/v1/token',
2929
'verb' => 'POST'
3030
],
31+
[
32+
/** @see \OCA\OAuth2\Controller\OauthApiController::pushToken() */
33+
'name' => 'OauthApi#pushToken',
34+
'url' => '/api/v1/pushtoken',
35+
'verb' => 'POST'
36+
],
3137
],
3238
];

apps/oauth2/lib/Controller/LoginRedirectorController.php

Lines changed: 11 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -30,24 +30,16 @@
3030

3131
#[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)]
3232
class LoginRedirectorController extends Controller {
33-
/**
34-
* @param string $appName
35-
* @param IRequest $request
36-
* @param IURLGenerator $urlGenerator
37-
* @param ClientMapper $clientMapper
38-
* @param ISession $session
39-
* @param IL10N $l
40-
*/
4133
public function __construct(
4234
string $appName,
4335
IRequest $request,
44-
private IURLGenerator $urlGenerator,
45-
private ClientMapper $clientMapper,
46-
private ISession $session,
47-
private IL10N $l,
48-
private ISecureRandom $random,
49-
private IAppConfig $appConfig,
50-
private IConfig $config,
36+
private readonly IURLGenerator $urlGenerator,
37+
private readonly ClientMapper $clientMapper,
38+
private readonly ISession $session,
39+
private readonly IL10N $l,
40+
private readonly ISecureRandom $random,
41+
private readonly IAppConfig $appConfig,
42+
private readonly IConfig $config,
5143
) {
5244
parent::__construct($appName, $request);
5345
}
@@ -62,15 +54,14 @@ public function __construct(
6254
* @return TemplateResponse<Http::STATUS_OK, array{}>|RedirectResponse<Http::STATUS_SEE_OTHER, array{}>
6355
*
6456
* 200: Client not found
65-
* 303: Redirect to login URL
57+
* 303: Redirect to the login URL
6658
*/
6759
#[PublicPage]
6860
#[NoCSRFRequired]
6961
#[UseSession]
70-
public function authorize($client_id,
71-
$state,
72-
$response_type,
73-
string $redirect_uri = ''): TemplateResponse|RedirectResponse {
62+
public function authorize(
63+
string $client_id, string $state, string $response_type, string $redirect_uri = '',
64+
): TemplateResponse|RedirectResponse {
7465
try {
7566
$client = $this->clientMapper->getByIdentifier($client_id);
7667
} catch (ClientNotFoundException $e) {

apps/oauth2/lib/Controller/OauthApiController.php

Lines changed: 128 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,19 @@
2424
use OCP\AppFramework\Utility\ITimeFactory;
2525
use OCP\Authentication\Exceptions\ExpiredTokenException;
2626
use OCP\Authentication\Exceptions\InvalidTokenException;
27+
use OCP\Authentication\Token\IToken;
2728
use OCP\DB\Exception;
29+
use OCP\GlobalScale\IConfig as GlobalScaleConfig;
30+
use OCP\GlobalScale\IGlobalScaleService;
2831
use OCP\IDBConnection;
2932
use OCP\IRequest;
33+
use OCP\IURLGenerator;
34+
use OCP\IUserManager;
3035
use OCP\Security\Bruteforce\IThrottler;
3136
use OCP\Security\ICrypto;
3237
use OCP\Security\ISecureRandom;
38+
use Psr\Container\ContainerExceptionInterface;
39+
use Psr\Container\ContainerInterface;
3340
use Psr\Log\LoggerInterface;
3441

3542
#[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)]
@@ -40,16 +47,20 @@ class OauthApiController extends Controller {
4047
public function __construct(
4148
string $appName,
4249
IRequest $request,
43-
private ICrypto $crypto,
44-
private AccessTokenMapper $accessTokenMapper,
45-
private ClientMapper $clientMapper,
46-
private TokenProvider $tokenProvider,
47-
private ISecureRandom $secureRandom,
48-
private ITimeFactory $time,
49-
private LoggerInterface $logger,
50-
private IThrottler $throttler,
51-
private ITimeFactory $timeFactory,
52-
private IDBConnection $db,
50+
private readonly ICrypto $crypto,
51+
private readonly AccessTokenMapper $accessTokenMapper,
52+
private readonly ClientMapper $clientMapper,
53+
private readonly TokenProvider $tokenProvider,
54+
private readonly ISecureRandom $secureRandom,
55+
private readonly ITimeFactory $time,
56+
private readonly LoggerInterface $logger,
57+
private readonly IThrottler $throttler,
58+
private readonly ITimeFactory $timeFactory,
59+
private readonly IDBConnection $db,
60+
private readonly GlobalScaleConfig $globalScaleConfig,
61+
private readonly IUserManager $userManager,
62+
private readonly IURLGenerator $urlGenerator,
63+
private readonly ContainerInterface $container,
5364
) {
5465
parent::__construct($appName, $request);
5566
}
@@ -63,7 +74,7 @@ public function __construct(
6374
* @param ?string $client_id Client ID
6475
* @param ?string $client_secret Client secret
6576
* @throws Exception
66-
* @return JSONResponse<Http::STATUS_OK, array{access_token: string, token_type: string, expires_in: int, refresh_token: string, user_id: string}, array{}>|JSONResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>
77+
* @return JSONResponse<Http::STATUS_OK, array{access_token: string, token_type: string, expires_in: int, refresh_token: string, user_id: string, "x.nc-gss.secondary_url"?: ?string}, array{}>|JSONResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>
6778
*
6879
* 200: Token returned
6980
* 400: Getting token is not possible
@@ -212,7 +223,8 @@ public function getToken(
212223
);
213224

214225
// Expiration is in 1 hour again
215-
$appToken->setExpires($this->time->getTime() + 3600);
226+
$expires = $this->time->getTime() + 3600;
227+
$appToken->setExpires($expires);
216228
$this->tokenProvider->updateToken($appToken);
217229

218230
$this->db->commit();
@@ -229,14 +241,109 @@ public function getToken(
229241

230242
$this->throttler->resetDelay($this->request->getRemoteAddress(), 'login', ['user' => $appToken->getUID()]);
231243

232-
return new JSONResponse(
233-
[
234-
'access_token' => $newToken,
235-
'token_type' => 'Bearer',
236-
'expires_in' => 3600,
237-
'refresh_token' => $newCode,
238-
'user_id' => $appToken->getUID(),
239-
]
240-
);
244+
$data = [
245+
'access_token' => $newToken,
246+
'token_type' => 'Bearer',
247+
'expires_in' => 3600,
248+
'refresh_token' => $newCode,
249+
'user_id' => $appToken->getUID(),
250+
];
251+
252+
if ($this->globalScaleConfig->isGlobalScaleEnabled() && $this->globalScaleConfig->isPrimary()) {
253+
// Also make sure the access token is available on the secondary instance
254+
$data['x.nc-gss.secondary_url'] = $this->pushTokenToSecondary($appToken, $newToken, $expires);
255+
}
256+
257+
return new JSONResponse($data);
258+
}
259+
260+
/**
261+
* Push the freshly issued app token to the secondary instance holding the
262+
* user's account, so the OAuth client can use it there directly.
263+
*/
264+
private function pushTokenToSecondary(IToken $appToken, string $newToken, ?int $expires): ?string {
265+
$user = $this->userManager->get($appToken->getUID());
266+
if ($user === null) {
267+
$this->logger->warning('could not push oauth token to secondary: unknown user', ['uid' => $appToken->getUID()]);
268+
return null;
269+
}
270+
271+
try {
272+
/** @var IGlobalScaleService $globalScaleService */
273+
$globalScaleService = $this->container->get(IGlobalScaleService::class);
274+
} catch (ContainerExceptionInterface $e) {
275+
$this->logger->warning('could not push oauth token to secondary: globalsiteselector is not available', ['exception' => $e]);
276+
return null;
277+
}
278+
279+
try {
280+
return $globalScaleService->sendToSecondary($user, $this->urlGenerator->linkToRoute('oauth2.OauthApi.pushToken'), [
281+
'uid' => $appToken->getUID(),
282+
'loginName' => $appToken->getLoginName(),
283+
'name' => $appToken->getName(),
284+
'type' => $appToken->getType(),
285+
'remember' => $appToken->getRemember(),
286+
'scope' => $appToken->getScopeAsArray(),
287+
'expires' => $expires,
288+
'token' => $newToken,
289+
]);
290+
} catch (\Exception $e) {
291+
$this->logger->warning('could not push oauth token to secondary', ['exception' => $e]);
292+
}
293+
return null;
294+
}
295+
296+
/**
297+
* Receive an app token pushed from the primary instance, so it can be used
298+
* directly against this (secondary) instance.
299+
*/
300+
#[PublicPage]
301+
#[NoCSRFRequired]
302+
#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)]
303+
#[BruteForceProtection(action: 'oauth2PushToken')]
304+
public function pushToken(string $jwt): JSONResponse {
305+
if (!$this->globalScaleConfig->isGlobalScaleEnabled() || !$this->globalScaleConfig->isSecondary() || $jwt === '') {
306+
$response = new JSONResponse([], Http::STATUS_BAD_REQUEST);
307+
$response->throttle();
308+
return $response;
309+
}
310+
311+
try {
312+
/** @var IGlobalScaleService $globalScaleService */
313+
$globalScaleService = $this->container->get(IGlobalScaleService::class);
314+
} catch (ContainerExceptionInterface $e) {
315+
$this->logger->warning('could not receive oauth token from primary: globalsiteselector is not available', ['exception' => $e]);
316+
$response = new JSONResponse([], Http::STATUS_BAD_REQUEST);
317+
$response->throttle();
318+
return $response;
319+
}
320+
321+
try {
322+
$decoded = $globalScaleService->decodePayload($jwt);
323+
324+
$uid = (string)$decoded['uid'];
325+
if (!$this->userManager->userExists($uid)) {
326+
throw new \InvalidArgumentException('unknown user: ' . $uid);
327+
}
328+
329+
$this->tokenProvider->generateToken(
330+
(string)$decoded['token'],
331+
$uid,
332+
(string)$decoded['loginName'],
333+
null,
334+
(string)$decoded['name'],
335+
(int)$decoded['type'],
336+
(int)$decoded['remember'],
337+
(array)$decoded['scope'],
338+
$decoded['expires'] !== null ? (int)$decoded['expires'] : null,
339+
);
340+
} catch (\Exception $e) {
341+
$this->logger->warning('could not create pushed oauth token', ['exception' => $e]);
342+
$response = new JSONResponse([], Http::STATUS_BAD_REQUEST);
343+
$response->throttle();
344+
return $response;
345+
}
346+
347+
return new JSONResponse([]);
241348
}
242349
}

apps/oauth2/openapi.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@
8888
}
8989
},
9090
"303": {
91-
"description": "Redirect to login URL",
91+
"description": "Redirect to the login URL",
9292
"headers": {
9393
"Location": {
9494
"schema": {
@@ -199,6 +199,10 @@
199199
},
200200
"user_id": {
201201
"type": "string"
202+
},
203+
"x.nc-gss.secondary_url": {
204+
"type": "string",
205+
"nullable": true
202206
}
203207
}
204208
}

0 commit comments

Comments
 (0)