Skip to content

Commit 65059fc

Browse files
feat(cloud_federation_api): add token exchange endpoint issuing JWT access tokens
Co-authored-by: Micke Nordin <kano@sunet.se> Signed-off-by: Micke Nordin <kano@sunet.se> Signed-off-by: Enrique Pérez Arnaud <enrique@cazalla.net>
1 parent 977beac commit 65059fc

11 files changed

Lines changed: 915 additions & 7 deletions

File tree

apps/cloud_federation_api/appinfo/info.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,8 @@
2121
<dependencies>
2222
<nextcloud min-version="35" max-version="35"/>
2323
</dependencies>
24+
25+
<background-jobs>
26+
<job>OCA\CloudFederationAPI\BackgroundJob\CleanupExpiredOcmTokensJob</job>
27+
</background-jobs>
2428
</info>

apps/cloud_federation_api/composer/composer/autoload_classmap.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,18 @@
88
return array(
99
'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php',
1010
'OCA\\CloudFederationAPI\\AppInfo\\Application' => $baseDir . '/../lib/AppInfo/Application.php',
11+
'OCA\\CloudFederationAPI\\BackgroundJob\\CleanupExpiredOcmTokensJob' => $baseDir . '/../lib/BackgroundJob/CleanupExpiredOcmTokensJob.php',
1112
'OCA\\CloudFederationAPI\\Capabilities' => $baseDir . '/../lib/Capabilities.php',
1213
'OCA\\CloudFederationAPI\\Config' => $baseDir . '/../lib/Config.php',
1314
'OCA\\CloudFederationAPI\\Controller\\OCMRequestController' => $baseDir . '/../lib/Controller/OCMRequestController.php',
1415
'OCA\\CloudFederationAPI\\Controller\\RequestHandlerController' => $baseDir . '/../lib/Controller/RequestHandlerController.php',
16+
'OCA\\CloudFederationAPI\\Controller\\TokenController' => $baseDir . '/../lib/Controller/TokenController.php',
1517
'OCA\\CloudFederationAPI\\Db\\FederatedInvite' => $baseDir . '/../lib/Db/FederatedInvite.php',
1618
'OCA\\CloudFederationAPI\\Db\\FederatedInviteMapper' => $baseDir . '/../lib/Db/FederatedInviteMapper.php',
19+
'OCA\\CloudFederationAPI\\Db\\OcmTokenMap' => $baseDir . '/../lib/Db/OcmTokenMap.php',
20+
'OCA\\CloudFederationAPI\\Db\\OcmTokenMapMapper' => $baseDir . '/../lib/Db/OcmTokenMapMapper.php',
1721
'OCA\\CloudFederationAPI\\Events\\FederatedInviteAcceptedEvent' => $baseDir . '/../lib/Events/FederatedInviteAcceptedEvent.php',
1822
'OCA\\CloudFederationAPI\\Migration\\Version1016Date202502262004' => $baseDir . '/../lib/Migration/Version1016Date202502262004.php',
23+
'OCA\\CloudFederationAPI\\Migration\\Version1017Date20260306120000' => $baseDir . '/../lib/Migration/Version1017Date20260306120000.php',
1924
'OCA\\CloudFederationAPI\\ResponseDefinitions' => $baseDir . '/../lib/ResponseDefinitions.php',
2025
);

apps/cloud_federation_api/composer/composer/autoload_static.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,19 @@ class ComposerStaticInitCloudFederationAPI
2323
public static $classMap = array (
2424
'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php',
2525
'OCA\\CloudFederationAPI\\AppInfo\\Application' => __DIR__ . '/..' . '/../lib/AppInfo/Application.php',
26+
'OCA\\CloudFederationAPI\\BackgroundJob\\CleanupExpiredOcmTokensJob' => __DIR__ . '/..' . '/../lib/BackgroundJob/CleanupExpiredOcmTokensJob.php',
2627
'OCA\\CloudFederationAPI\\Capabilities' => __DIR__ . '/..' . '/../lib/Capabilities.php',
2728
'OCA\\CloudFederationAPI\\Config' => __DIR__ . '/..' . '/../lib/Config.php',
2829
'OCA\\CloudFederationAPI\\Controller\\OCMRequestController' => __DIR__ . '/..' . '/../lib/Controller/OCMRequestController.php',
2930
'OCA\\CloudFederationAPI\\Controller\\RequestHandlerController' => __DIR__ . '/..' . '/../lib/Controller/RequestHandlerController.php',
31+
'OCA\\CloudFederationAPI\\Controller\\TokenController' => __DIR__ . '/..' . '/../lib/Controller/TokenController.php',
3032
'OCA\\CloudFederationAPI\\Db\\FederatedInvite' => __DIR__ . '/..' . '/../lib/Db/FederatedInvite.php',
3133
'OCA\\CloudFederationAPI\\Db\\FederatedInviteMapper' => __DIR__ . '/..' . '/../lib/Db/FederatedInviteMapper.php',
34+
'OCA\\CloudFederationAPI\\Db\\OcmTokenMap' => __DIR__ . '/..' . '/../lib/Db/OcmTokenMap.php',
35+
'OCA\\CloudFederationAPI\\Db\\OcmTokenMapMapper' => __DIR__ . '/..' . '/../lib/Db/OcmTokenMapMapper.php',
3236
'OCA\\CloudFederationAPI\\Events\\FederatedInviteAcceptedEvent' => __DIR__ . '/..' . '/../lib/Events/FederatedInviteAcceptedEvent.php',
3337
'OCA\\CloudFederationAPI\\Migration\\Version1016Date202502262004' => __DIR__ . '/..' . '/../lib/Migration/Version1016Date202502262004.php',
38+
'OCA\\CloudFederationAPI\\Migration\\Version1017Date20260306120000' => __DIR__ . '/..' . '/../lib/Migration/Version1017Date20260306120000.php',
3439
'OCA\\CloudFederationAPI\\ResponseDefinitions' => __DIR__ . '/..' . '/../lib/ResponseDefinitions.php',
3540
);
3641

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\CloudFederationAPI\BackgroundJob;
11+
12+
use OCA\CloudFederationAPI\Db\OcmTokenMapMapper;
13+
use OCP\AppFramework\Utility\ITimeFactory;
14+
use OCP\BackgroundJob\TimedJob;
15+
16+
/**
17+
* Periodically purge expired OCM access token mappings from ocm_token_map.
18+
*
19+
* The corresponding oc_authtoken entries (TEMPORARY_TOKEN with an expires
20+
* timestamp) are cleaned up by Nextcloud's own token expiry jobs.
21+
*/
22+
class CleanupExpiredOcmTokensJob extends TimedJob {
23+
public function __construct(
24+
ITimeFactory $timeFactory,
25+
private readonly OcmTokenMapMapper $mapper,
26+
) {
27+
parent::__construct($timeFactory);
28+
29+
$this->setInterval(6 * 60 * 60); // run every 6 hours
30+
$this->setTimeSensitivity(self::TIME_INSENSITIVE);
31+
}
32+
33+
#[\Override]
34+
protected function run($argument): void {
35+
$this->mapper->deleteExpired($this->time->getTime());
36+
}
37+
}
Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
<?php
2+
3+
/**
4+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: AGPL-3.0-or-later
6+
*/
7+
8+
namespace OCA\CloudFederationAPI\Controller;
9+
10+
use Firebase\JWT\JWT;
11+
use OC\Authentication\Token\IProvider;
12+
use OC\OCM\OCMSignatoryManager;
13+
use OCA\CloudFederationAPI\Db\OcmTokenMap;
14+
use OCA\CloudFederationAPI\Db\OcmTokenMapMapper;
15+
use OCP\AppFramework\ApiController;
16+
use OCP\AppFramework\Http;
17+
use OCP\AppFramework\Http\Attribute\FrontpageRoute;
18+
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
19+
use OCP\AppFramework\Http\Attribute\PublicPage;
20+
use OCP\AppFramework\Http\DataResponse;
21+
use OCP\AppFramework\Utility\ITimeFactory;
22+
use OCP\Authentication\Exceptions\ExpiredTokenException;
23+
use OCP\Authentication\Exceptions\InvalidTokenException;
24+
use OCP\Authentication\Token\IToken;
25+
use OCP\IAppConfig;
26+
use OCP\IRequest;
27+
use OCP\Security\ISecureRandom;
28+
use OCP\Security\Signature\Exceptions\IncomingRequestException;
29+
use OCP\Security\Signature\Exceptions\SignatoryNotFoundException;
30+
use OCP\Security\Signature\Exceptions\SignatureException;
31+
use OCP\Security\Signature\Exceptions\SignatureNotFoundException;
32+
use OCP\Security\Signature\IIncomingSignedRequest;
33+
use OCP\Security\Signature\ISignatureManager;
34+
use OCP\Security\Signature\Model\Signatory;
35+
use OCP\Share\IManager as IShareManager;
36+
use Psr\Log\LoggerInterface;
37+
38+
/**
39+
* Controller for the /token endpoint
40+
* Exchanges long-lived refresh tokens for short-lived access tokens
41+
*
42+
* @since 32.0.0
43+
*/
44+
class TokenController extends ApiController {
45+
public function __construct(
46+
IRequest $request,
47+
private readonly IProvider $tokenProvider,
48+
private readonly ISecureRandom $random,
49+
private readonly ITimeFactory $timeFactory,
50+
private readonly LoggerInterface $logger,
51+
private readonly ISignatureManager $signatureManager,
52+
private readonly OCMSignatoryManager $signatoryManager,
53+
private readonly IAppConfig $appConfig,
54+
private readonly OcmTokenMapMapper $ocmTokenMapMapper,
55+
private readonly IShareManager $shareManager,
56+
) {
57+
parent::__construct('cloud_federation_api', $request);
58+
}
59+
60+
/**
61+
* Verify the signature of incoming request if available
62+
*
63+
* @return IIncomingSignedRequest|null null if remote does not support signed requests
64+
* @throws IncomingRequestException if signature is required but invalid
65+
*/
66+
private function verifySignedRequest(): ?IIncomingSignedRequest {
67+
try {
68+
$signedRequest = $this->signatureManager->getIncomingSignedRequest($this->signatoryManager);
69+
$this->logger->debug('Token request signature verified', [
70+
'origin' => $signedRequest->getOrigin()
71+
]);
72+
return $signedRequest;
73+
} catch (SignatureNotFoundException|SignatoryNotFoundException $e) {
74+
$this->logger->debug('Token request not signed', ['exception' => $e]);
75+
76+
if ($this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_ENFORCED, lazy: true)) {
77+
$this->logger->notice('Rejected unsigned token request', ['exception' => $e]);
78+
throw new IncomingRequestException('Unsigned request not allowed');
79+
}
80+
return null;
81+
} catch (SignatureException $e) {
82+
$this->logger->warning('Invalid token request signature', ['exception' => $e]);
83+
throw new IncomingRequestException('Invalid signature');
84+
}
85+
}
86+
87+
/**
88+
* @return array{0: string, 1: string} [JWS algorithm, key material accepted by firebase/php-jwt]
89+
* @throws \RuntimeException if the key cannot be parsed or its type is unsupported
90+
*/
91+
private function resolveJwtSigningKey(string $privateKeyPem): array {
92+
$key = openssl_pkey_get_private($privateKeyPem);
93+
if ($key === false) {
94+
throw new \RuntimeException('Cannot parse signatory private key');
95+
}
96+
$details = openssl_pkey_get_details($key);
97+
98+
if (isset($details['rsa'])) {
99+
$algorithm = $details['bits'] >= 4096 ? 'RS512' : 'RS256';
100+
return [$algorithm, $privateKeyPem];
101+
}
102+
if (isset($details['ed25519'])) {
103+
$der = base64_decode((string)preg_replace('/-----[^-]+-----|\s+/', '', $privateKeyPem), true);
104+
if ($der === false || strlen($der) < 32) {
105+
throw new \RuntimeException('Cannot decode Ed25519 PKCS#8 PEM');
106+
}
107+
// RFC 8410 §7: Ed25519 PKCS#8 v1 ends with the 32-byte raw seed
108+
$seed = substr($der, -32);
109+
$secretKey = sodium_crypto_sign_secretkey(sodium_crypto_sign_seed_keypair($seed));
110+
return ['EdDSA', base64_encode($secretKey)];
111+
}
112+
113+
throw new \RuntimeException('Unsupported signatory key type for JWT access token');
114+
}
115+
116+
/**
117+
* Exchange a refresh token for a short-lived access token
118+
*
119+
* @param string $grant_type OAuth grant type, must be `authorization_code`
120+
* @param string $code The refresh token to exchange for an access token
121+
* @return DataResponse<Http::STATUS_OK, array{access_token: string, token_type: string, expires_in: int}, array{}>|DataResponse<Http::STATUS_UNAUTHORIZED|Http::STATUS_BAD_REQUEST|Http::STATUS_INTERNAL_SERVER_ERROR, array{error: string}, array{}>
122+
*
123+
* 200: Access token successfully generated
124+
* 400: Bad request - missing refresh token or invalid request format
125+
* 401: Unauthorized - invalid or expired refresh token, or invalid signature
126+
* 500: Internal server error
127+
*/
128+
#[PublicPage]
129+
#[NoCSRFRequired]
130+
#[FrontpageRoute(verb: 'POST', url: '/api/v1/access-token')]
131+
public function accessToken(string $grant_type = '', string $code = ''): DataResponse {
132+
try {
133+
$signedRequest = $this->verifySignedRequest();
134+
} catch (IncomingRequestException $e) {
135+
$this->logger->warning('Token request signature verification failed', [
136+
'exception' => $e
137+
]);
138+
return new DataResponse(
139+
['error' => 'invalid_request'],
140+
Http::STATUS_UNAUTHORIZED
141+
);
142+
}
143+
144+
if ($grant_type !== 'authorization_code') {
145+
return new DataResponse(
146+
['error' => 'unsupported_grant_type'],
147+
Http::STATUS_BAD_REQUEST
148+
);
149+
}
150+
151+
if ($code === '') {
152+
return new DataResponse(
153+
['error' => 'refresh_token is required'],
154+
Http::STATUS_BAD_REQUEST
155+
);
156+
}
157+
158+
$refreshToken = $code;
159+
160+
try {
161+
$token = $this->tokenProvider->getToken($refreshToken);
162+
163+
if ($token->getType() !== IToken::PERMANENT_TOKEN) {
164+
$this->logger->warning('Attempted to use non-permanent token as refresh token', [
165+
'tokenId' => $token->getId(),
166+
]);
167+
return new DataResponse(
168+
['error' => 'invalid_grant'],
169+
Http::STATUS_UNAUTHORIZED
170+
);
171+
}
172+
173+
// Revoke the previous access token for this refresh token, if any.
174+
$existingMapping = $this->ocmTokenMapMapper->findByRefreshToken($refreshToken);
175+
if ($existingMapping !== null) {
176+
try {
177+
$this->tokenProvider->invalidateTokenById(
178+
$token->getUID(),
179+
$existingMapping->getAccessTokenId()
180+
);
181+
} catch (\Exception) {
182+
// Token may already be gone; ignore.
183+
}
184+
$this->ocmTokenMapMapper->delete($existingMapping);
185+
}
186+
187+
$share = $this->shareManager->getShareByToken($refreshToken);
188+
$expiresIn = 3600; // 1 hour in seconds
189+
$issuedAt = $this->timeFactory->getTime();
190+
$expiresAt = $issuedAt + $expiresIn;
191+
192+
$signatory = $this->signatoryManager->getLocalSignatory();
193+
$keyId = $signatory->getKeyId();
194+
$issuer = parse_url($keyId, PHP_URL_SCHEME) . '://' . Signatory::extractIdentityFromUri($keyId);
195+
196+
[$jwtAlgorithm, $jwtKey] = $this->resolveJwtSigningKey($signatory->getPrivateKey());
197+
198+
$payload = [
199+
'iss' => $issuer,
200+
'sub' => $share->getShareOwner(),
201+
'aud' => $share->getSharedWith(),
202+
'client_id' => (string)$token->getId(),
203+
'iat' => $issuedAt,
204+
'exp' => $expiresAt,
205+
'jti' => $this->random->generate(16, ISecureRandom::CHAR_LOWER . ISecureRandom::CHAR_UPPER . ISecureRandom::CHAR_DIGITS),
206+
];
207+
208+
$accessTokenString = JWT::encode($payload, $jwtKey, $jwtAlgorithm, $keyId);
209+
210+
$accessToken = $this->tokenProvider->generateToken(
211+
$accessTokenString,
212+
$token->getUID(),
213+
$token->getLoginName(),
214+
null, // No password for access tokens
215+
IToken::OCM_ACCESS_TOKEN_NAME,
216+
IToken::TEMPORARY_TOKEN,
217+
IToken::DO_NOT_REMEMBER
218+
);
219+
220+
$accessToken->setExpires($expiresAt);
221+
$this->tokenProvider->updateToken($accessToken);
222+
223+
$mapping = new OcmTokenMap();
224+
$mapping->setAccessTokenId($accessToken->getId());
225+
$mapping->setRefreshToken($refreshToken);
226+
$mapping->setExpires($expiresAt);
227+
$this->ocmTokenMapMapper->insert($mapping);
228+
229+
return new DataResponse([
230+
'access_token' => $accessTokenString,
231+
'token_type' => 'Bearer',
232+
'expires_in' => $expiresIn,
233+
], Http::STATUS_OK);
234+
} catch (InvalidTokenException $e) {
235+
$this->logger->info('Invalid refresh token provided', [
236+
'exception' => $e,
237+
]);
238+
return new DataResponse(
239+
['error' => 'invalid_grant'],
240+
Http::STATUS_UNAUTHORIZED
241+
);
242+
} catch (ExpiredTokenException $e) {
243+
$this->logger->info('Expired refresh token provided', [
244+
'exception' => $e,
245+
]);
246+
return new DataResponse(
247+
['error' => 'invalid_grant'],
248+
Http::STATUS_UNAUTHORIZED
249+
);
250+
} catch (\Exception $e) {
251+
$this->logger->error('Error generating access token', [
252+
'exception' => $e,
253+
]);
254+
return new DataResponse(
255+
['error' => 'server_error'],
256+
Http::STATUS_INTERNAL_SERVER_ERROR
257+
);
258+
}
259+
}
260+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\CloudFederationAPI\Db;
11+
12+
use OCP\AppFramework\Db\Entity;
13+
use OCP\DB\Types;
14+
15+
/**
16+
* Maps a short-lived OCM access token (by its oc_authtoken id) to the
17+
* long-lived refresh token it was issued for.
18+
*
19+
* @method int getAccessTokenId()
20+
* @method void setAccessTokenId(int $id)
21+
* @method string getRefreshToken()
22+
* @method void setRefreshToken(string $token)
23+
* @method int getExpires()
24+
* @method void setExpires(int $expires)
25+
*/
26+
class OcmTokenMap extends Entity {
27+
/** @var int ID of the access token row in oc_authtoken */
28+
protected $accessTokenId;
29+
30+
/** @var string The refresh token this access token was issued for */
31+
protected $refreshToken;
32+
33+
/** @var int Unix timestamp when the access token expires */
34+
protected $expires;
35+
36+
public function __construct() {
37+
$this->addType('accessTokenId', Types::INTEGER);
38+
$this->addType('refreshToken', Types::STRING);
39+
$this->addType('expires', Types::INTEGER);
40+
}
41+
}

0 commit comments

Comments
 (0)