Skip to content

Commit 30d6dc2

Browse files
CarlSchwanbackportbot[bot]
authored andcommitted
feat(oauth2): Forward app tokens from primary to secondary in global scale
Assisted-by: ClaudeCode:claude-sonnet-5 Signed-off-by: Carl Schwan <carl@carlschwan.eu>
1 parent 8714319 commit 30d6dc2

10 files changed

Lines changed: 584 additions & 68 deletions

File tree

apps/oauth2/appinfo/routes.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,11 @@
2626
'url' => '/api/v1/token',
2727
'verb' => 'POST'
2828
],
29+
[
30+
/** @see \OCA\OAuth2\Controller\OauthApiController::pushToken() */
31+
'name' => 'OauthApi#pushToken',
32+
'url' => '/api/v1/pushtoken',
33+
'verb' => 'POST'
34+
],
2935
],
3036
];

apps/oauth2/lib/Controller/LoginRedirectorController.php

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

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

apps/oauth2/lib/Controller/OauthApiController.php

Lines changed: 128 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,19 @@
2323
use OCP\AppFramework\Utility\ITimeFactory;
2424
use OCP\Authentication\Exceptions\ExpiredTokenException;
2525
use OCP\Authentication\Exceptions\InvalidTokenException;
26+
use OCP\Authentication\Token\IToken;
2627
use OCP\DB\Exception;
28+
use OCP\GlobalScale\IConfig as GlobalScaleConfig;
29+
use OCP\GlobalScale\IGlobalScaleService;
2730
use OCP\IDBConnection;
2831
use OCP\IRequest;
32+
use OCP\IURLGenerator;
33+
use OCP\IUserManager;
2934
use OCP\Security\Bruteforce\IThrottler;
3035
use OCP\Security\ICrypto;
3136
use OCP\Security\ISecureRandom;
37+
use Psr\Container\ContainerExceptionInterface;
38+
use Psr\Container\ContainerInterface;
3239
use Psr\Log\LoggerInterface;
3340

3441
#[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)]
@@ -39,16 +46,20 @@ class OauthApiController extends Controller {
3946
public function __construct(
4047
string $appName,
4148
IRequest $request,
42-
private ICrypto $crypto,
43-
private AccessTokenMapper $accessTokenMapper,
44-
private ClientMapper $clientMapper,
45-
private TokenProvider $tokenProvider,
46-
private ISecureRandom $secureRandom,
47-
private ITimeFactory $time,
48-
private LoggerInterface $logger,
49-
private IThrottler $throttler,
50-
private ITimeFactory $timeFactory,
51-
private IDBConnection $db,
49+
private readonly ICrypto $crypto,
50+
private readonly AccessTokenMapper $accessTokenMapper,
51+
private readonly ClientMapper $clientMapper,
52+
private readonly TokenProvider $tokenProvider,
53+
private readonly ISecureRandom $secureRandom,
54+
private readonly ITimeFactory $time,
55+
private readonly LoggerInterface $logger,
56+
private readonly IThrottler $throttler,
57+
private readonly ITimeFactory $timeFactory,
58+
private readonly IDBConnection $db,
59+
private readonly GlobalScaleConfig $globalScaleConfig,
60+
private readonly IUserManager $userManager,
61+
private readonly IURLGenerator $urlGenerator,
62+
private readonly ContainerInterface $container,
5263
) {
5364
parent::__construct($appName, $request);
5465
}
@@ -62,7 +73,7 @@ public function __construct(
6273
* @param ?string $client_id Client ID
6374
* @param ?string $client_secret Client secret
6475
* @throws Exception
65-
* @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{}>
76+
* @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{}>
6677
*
6778
* 200: Token returned
6879
* 400: Getting token is not possible
@@ -211,7 +222,8 @@ public function getToken(
211222
);
212223

213224
// Expiration is in 1 hour again
214-
$appToken->setExpires($this->time->getTime() + 3600);
225+
$expires = $this->time->getTime() + 3600;
226+
$appToken->setExpires($expires);
215227
$this->tokenProvider->updateToken($appToken);
216228

217229
$this->db->commit();
@@ -228,14 +240,109 @@ public function getToken(
228240

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

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

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)