signatureManager->getIncomingSignedRequest($this->signatoryManager); $this->logger->debug('Token request signature verified', [ 'origin' => $signedRequest->getOrigin() ]); return $signedRequest; } catch (SignatureNotFoundException|SignatoryNotFoundException $e) { $this->logger->debug('Token request not signed', ['exception' => $e]); if ($this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_ENFORCED, lazy: true)) { $this->logger->notice('Rejected unsigned token request', ['exception' => $e]); throw new IncomingRequestException('Unsigned request not allowed'); } return null; } catch (SignatureException $e) { $this->logger->warning('Invalid token request signature', ['exception' => $e]); throw new IncomingRequestException('Invalid signature'); } } /** * @return array{0: string, 1: string} [JWS algorithm, key material accepted by firebase/php-jwt] * @throws \RuntimeException if the key cannot be parsed or its type is unsupported */ private function resolveJwtSigningKey(string $privateKeyPem): array { $key = openssl_pkey_get_private($privateKeyPem); if ($key === false) { throw new \RuntimeException('Cannot parse signatory private key'); } $details = openssl_pkey_get_details($key); if (isset($details['rsa'])) { $algorithm = $details['bits'] >= 4096 ? 'RS512' : 'RS256'; return [$algorithm, $privateKeyPem]; } if (isset($details['ec'])) { $algorithm = match ($details['ec']['curve_name'] ?? '') { 'prime256v1' => 'ES256', 'secp384r1' => 'ES384', default => throw new \RuntimeException('Unsupported EC curve for JWT access token: ' . ($details['ec']['curve_name'] ?? 'unknown')), }; return [$algorithm, $privateKeyPem]; } throw new \RuntimeException('Unsupported signatory key type for JWT access token'); } /** * Exchange a refresh token for a short-lived access token * * @param string $grant_type OAuth grant type, must be `authorization_code` * @param string $code The refresh token to exchange for an access token * @return DataResponse|DataResponse * * 200: Access token successfully generated * 400: Bad request - missing refresh token or invalid request format * 401: Unauthorized - invalid or expired refresh token, or invalid signature * 500: Internal server error */ #[PublicPage] #[NoCSRFRequired] #[FrontpageRoute(verb: 'POST', url: '/api/v1/access-token')] public function accessToken(string $grant_type = '', string $code = ''): DataResponse { try { $signedRequest = $this->verifySignedRequest(); } catch (IncomingRequestException $e) { $this->logger->warning('Token request signature verification failed', [ 'exception' => $e ]); return new DataResponse( ['error' => 'invalid_request'], Http::STATUS_UNAUTHORIZED ); } if ($grant_type !== 'authorization_code') { return new DataResponse( ['error' => 'unsupported_grant_type'], Http::STATUS_BAD_REQUEST ); } if ($code === '') { return new DataResponse( ['error' => 'refresh_token is required'], Http::STATUS_BAD_REQUEST ); } $refreshToken = $code; try { $token = $this->tokenProvider->getToken($refreshToken); if ($token->getType() !== IToken::PERMANENT_TOKEN) { $this->logger->warning('Attempted to use non-permanent token as refresh token', [ 'tokenId' => $token->getId(), ]); return new DataResponse( ['error' => 'invalid_grant'], Http::STATUS_UNAUTHORIZED ); } // After the first exchange the refresh token must only be usable to // obtain further access tokens, never as a direct filesystem/WebDAV // credential. Lock down its filesystem scope so a leaked refresh token // cannot be replayed as a bearer against the WebDAV endpoints. $scope = $token->getScopeAsArray(); if (($scope[IToken::SCOPE_FILESYSTEM] ?? true) !== false) { $scope[IToken::SCOPE_FILESYSTEM] = false; $token->setScope($scope); $this->tokenProvider->updateToken($token); } // Revoke the previous access token for this refresh token, if any. $existingMapping = $this->ocmTokenMapMapper->findByRefreshToken($refreshToken); if ($existingMapping !== null) { try { $this->tokenProvider->invalidateTokenById( $token->getUID(), $existingMapping->getAccessTokenId() ); } catch (\Exception) { // Token may already be gone; ignore. } $this->ocmTokenMapMapper->delete($existingMapping); } $share = $this->shareManager->getShareByToken($refreshToken); // access_token TTL from the refresh-token scope; default 3600, clamped 300..86400. $ttl = (int)($token->getScopeAsArray()['ocm_access_token_ttl'] ?? 3600); $expiresIn = max(300, min(86400, $ttl)); $issuedAt = $this->timeFactory->getTime(); $expiresAt = $issuedAt + $expiresIn; $signatory = $this->signatoryManager->getLocalJwksSignatory(); if ($signatory === null) { throw new \RuntimeException('No JWKS-published OCM signatory available to sign the access token'); } $keyId = $signatory->getKeyId(); $issuer = parse_url($keyId, PHP_URL_SCHEME) . '://' . Signatory::extractIdentityFromUri($keyId); [$jwtAlgorithm, $jwtKey] = $this->resolveJwtSigningKey($signatory->getPrivateKey()); $payload = [ 'iss' => $issuer, 'sub' => $share->getShareOwner(), 'aud' => $share->getSharedWith(), 'client_id' => $share->getId(), 'iat' => $issuedAt, 'exp' => $expiresAt, 'jti' => $this->random->generate(16, ISecureRandom::CHAR_LOWER . ISecureRandom::CHAR_UPPER . ISecureRandom::CHAR_DIGITS), ]; $accessTokenString = JWT::encode($payload, $jwtKey, $jwtAlgorithm, $keyId, ['typ' => 'at+jwt']); $accessToken = $this->tokenProvider->generateToken( $accessTokenString, $token->getUID(), $token->getLoginName(), null, // No password for access tokens IToken::OCM_ACCESS_TOKEN_NAME, IToken::TEMPORARY_TOKEN, IToken::DO_NOT_REMEMBER ); $accessToken->setExpires($expiresAt); $this->tokenProvider->updateToken($accessToken); $mapping = new OcmTokenMap(); $mapping->setAccessTokenId($accessToken->getId()); $mapping->setRefreshToken($refreshToken); $mapping->setExpires($expiresAt); $this->ocmTokenMapMapper->insert($mapping); return new DataResponse([ 'access_token' => $accessTokenString, 'token_type' => 'Bearer', 'expires_in' => $expiresIn, ], Http::STATUS_OK); } catch (InvalidTokenException $e) { $this->logger->info('Invalid refresh token provided', [ 'exception' => $e, ]); return new DataResponse( ['error' => 'invalid_grant'], Http::STATUS_UNAUTHORIZED ); } catch (ExpiredTokenException $e) { $this->logger->info('Expired refresh token provided', [ 'exception' => $e, ]); return new DataResponse( ['error' => 'invalid_grant'], Http::STATUS_UNAUTHORIZED ); } catch (\Exception $e) { $this->logger->error('Error generating access token', [ 'exception' => $e, ]); return new DataResponse( ['error' => 'server_error'], Http::STATUS_INTERNAL_SERVER_ERROR ); } } }