Skip to content

Commit 12fbef3

Browse files
authored
Fix new plugins registry download when OCI repositories require tokens (#6303) [ci fast]
Signed-off-by: jorgee <[email protected]>
1 parent d0d3e10 commit 12fbef3

File tree

5 files changed

+275
-1
lines changed

5 files changed

+275
-1
lines changed

modules/nf-commons/src/main/nextflow/plugin/HttpPluginRepository.groovy

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ class HttpPluginRepository implements PrefetchUpdateRepository {
102102

103103
@Override
104104
FileDownloader getFileDownloader() {
105-
return new SimpleFileDownloader()
105+
return new OciAwareFileDownloader()
106106
}
107107

108108
@Override
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/*
2+
* Copyright 2013-2025, Seqera Labs
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
*/
17+
18+
package nextflow.plugin
19+
20+
import groovy.transform.CompileStatic
21+
import groovy.util.logging.Slf4j
22+
import org.pf4j.update.SimpleFileDownloader
23+
24+
import java.nio.file.Files
25+
import java.nio.file.Path
26+
import java.util.regex.Pattern
27+
28+
/**
29+
* FileDownloader extension that enables the download of OCI compliant artifact that require a token authorization.
30+
*
31+
* @author Jorge Ejarque <[email protected]>
32+
*/
33+
@Slf4j
34+
@CompileStatic
35+
class OciAwareFileDownloader extends SimpleFileDownloader {
36+
37+
private static final Pattern WWW_AUTH_PATTERN = ~/Bearer realm="([^"]+)",\s*service="([^"]+)",\s*scope="([^"]+)"/
38+
39+
/**
40+
* OCI aware download with token authorization. Tries to download the artifact and if it fails checks the headers to get the
41+
* @param fileUrl source file
42+
* @return
43+
*/
44+
@Override
45+
protected Path downloadFileHttp(URL fileUrl) {
46+
47+
Path destination = Files.createTempDirectory("pf4j-update-downloader");
48+
destination.toFile().deleteOnExit();
49+
50+
String path = fileUrl.getPath();
51+
String fileName = path.substring(path.lastIndexOf('/') + 1);
52+
Path file = destination.resolve(fileName);
53+
HttpURLConnection conn = (HttpURLConnection) fileUrl.openConnection()
54+
conn.instanceFollowRedirects = true
55+
56+
if (conn.responseCode == HttpURLConnection.HTTP_UNAUTHORIZED) {
57+
def wwwAuth = conn.getHeaderField("WWW-Authenticate")
58+
if (wwwAuth?.contains("Bearer")) {
59+
log.debug("Received 401 — attempting OCI token auth")
60+
61+
def matcher = WWW_AUTH_PATTERN.matcher(wwwAuth)
62+
if (!matcher.find()) {
63+
throw new IOException("Invalid WWW-Authenticate header: $wwwAuth")
64+
}
65+
66+
def (realm, service, scope) = [matcher.group(1), matcher.group(2), matcher.group(3)]
67+
def tokenUrl = "${realm}?service=${URLEncoder.encode(service, 'UTF-8')}&scope=${URLEncoder.encode(scope, 'UTF-8')}"
68+
def token = fetchToken(tokenUrl)
69+
70+
// Retry download with Bearer token
71+
def authConn = (HttpURLConnection) fileUrl.openConnection()
72+
authConn.setRequestProperty("Authorization", "Bearer $token")
73+
authConn.instanceFollowRedirects = true
74+
75+
authConn.inputStream.withStream { input ->
76+
file.withOutputStream { out -> out << input }
77+
}
78+
79+
return file
80+
}
81+
}
82+
83+
// Fallback to default behavior
84+
conn.inputStream.withStream { input ->
85+
file.withOutputStream { out -> out << input }
86+
}
87+
return file
88+
}
89+
90+
private String fetchToken(String tokenUrl) {
91+
def conn = (HttpURLConnection) URI.create(tokenUrl).toURL().openConnection()
92+
conn.setRequestProperty("Accept", "application/json")
93+
94+
def json = conn.inputStream.getText("UTF-8")
95+
def matcher = json =~ /"token"\s*:\s*"([^"]+)"/
96+
if (matcher.find()) {
97+
return matcher.group(1)
98+
}
99+
throw new IOException("Token not found in response: $json")
100+
}
101+
}
102+
103+
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/*
2+
* Copyright 2013-2025, Seqera Labs
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package nextflow.plugin
18+
19+
import com.github.tomakehurst.wiremock.junit.WireMockRule
20+
import org.junit.Rule
21+
import spock.lang.Specification
22+
23+
import java.nio.file.Files
24+
25+
import static com.github.tomakehurst.wiremock.client.WireMock.*
26+
27+
class OciAwareFileDownloaderTest extends Specification {
28+
29+
@Rule
30+
WireMockRule wiremock = new WireMockRule(0)
31+
32+
OciAwareFileDownloader downloader
33+
34+
def setup() {
35+
downloader = new OciAwareFileDownloader()
36+
}
37+
38+
def 'should download file successfully without authentication'() {
39+
given:
40+
def fileContent = "test plugin archive content"
41+
wiremock.stubFor(get(urlPathEqualTo("/plugin.zip"))
42+
.willReturn(ok().withBody(fileContent))
43+
)
44+
45+
when:
46+
def downloadedFile = downloader.downloadFileHttp(new URL("${wiremock.baseUrl()}/plugin.zip"))
47+
48+
then:
49+
downloadedFile != null
50+
Files.exists(downloadedFile)
51+
downloadedFile.text == fileContent
52+
downloadedFile.fileName.toString() == "plugin.zip"
53+
54+
cleanup:
55+
if (downloadedFile) Files.deleteIfExists(downloadedFile)
56+
}
57+
58+
def 'should handle OCI token authentication when receiving 401 unauthorized'() {
59+
given:
60+
def fileContent = "authenticated plugin archive content"
61+
def tokenResponse = '{"token": "test-bearer-token-12345"}'
62+
def authServerUrl = "${wiremock.baseUrl()}/token"
63+
def wwwAuthHeader = "Bearer realm=\"${authServerUrl}\",service=\"registry.example.com\",scope=\"repository:plugins/nf-test:pull\""
64+
65+
// Token endpoint returns authentication token
66+
67+
wiremock.stubFor( get(urlPathEqualTo("/token"))
68+
.withQueryParam('service', equalTo('registry.example.com'))
69+
.withQueryParam('scope', equalTo('repository:plugins/nf-test:pull'))
70+
.willReturn( okJson(tokenResponse))
71+
)
72+
wiremock.stubFor(get(urlPathEqualTo("/plugin.zip"))
73+
.willReturn(unauthorized().withHeader("WWW-Authenticate", wwwAuthHeader))
74+
)
75+
76+
wiremock.stubFor(get(urlPathEqualTo("/plugin.zip"))
77+
.withHeader("Authorization", equalTo("Bearer test-bearer-token-12345") )
78+
.willReturn(ok().withBody(fileContent))
79+
)
80+
81+
when:
82+
def downloadedFile = downloader.downloadFileHttp(new URL("${wiremock.baseUrl()}/plugin.zip"))
83+
84+
then:
85+
downloadedFile != null
86+
Files.exists(downloadedFile)
87+
downloadedFile.text == fileContent
88+
89+
cleanup:
90+
Files.deleteIfExists(downloadedFile)
91+
}
92+
93+
def 'should throw IOException when WWW-Authenticate header is malformed'() {
94+
given:
95+
wiremock.stubFor(get(urlPathEqualTo("/plugin.zip"))
96+
.willReturn(unauthorized().withHeader("WWW-Authenticate", "Bearer invalid-header-format"))
97+
)
98+
99+
when:
100+
downloader.downloadFileHttp(new URL("${wiremock.baseUrl()}/plugin.zip"))
101+
102+
then:
103+
def ex = thrown(IOException)
104+
ex.message.contains("Invalid WWW-Authenticate header")
105+
}
106+
107+
def 'should throw IOException when token is not found in response'() {
108+
given:
109+
def invalidTokenResponse = '{"access_token": "wrong-field-name"}'
110+
def authServerUrl = "${wiremock.baseUrl()}/token"
111+
def wwwAuthHeader = "Bearer realm=\"${authServerUrl}\",service=\"registry.example.com\",scope=\"repository:plugins/nf-test:pull\""
112+
113+
wiremock.stubFor( get(urlPathEqualTo("/token"))
114+
.withQueryParam('service', equalTo('registry.example.com'))
115+
.withQueryParam('scope', equalTo('repository:plugins/nf-test:pull'))
116+
.willReturn( okJson(invalidTokenResponse))
117+
)
118+
wiremock.stubFor(get(urlPathEqualTo("/plugin.zip"))
119+
.willReturn(unauthorized().withHeader("WWW-Authenticate", wwwAuthHeader))
120+
)
121+
122+
when:
123+
downloader.downloadFileHttp(new URL("${wiremock.baseUrl()}/plugin.zip"))
124+
125+
then:
126+
def ex = thrown(IOException)
127+
ex.message.contains("Token not found in response")
128+
}
129+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
set -e
2+
3+
#
4+
# run normal mode
5+
#
6+
echo ''
7+
NXF_PLUGINS_INDEX_URL=https://plugin-registry.dev-tower.net/api NXF_PLUGINS_DIR=$PWD/.nextflow/plugins/ $NXF_RUN -plugins [email protected] | tee stdout
8+
9+
[[ `grep 'INFO' .nextflow.log | grep -c 'Downloading plugin [email protected]'` == 1 ]] || false
10+
[[ `grep -c 'Pipeline is starting using nf-ci-test-integration plugin' stdout` == 1 ]] || false
11+
[[ `grep 'INFO' .nextflow.log | grep -c 'Submitted process > sayhello'` == 1 ]] || false
12+
[[ `grep -c 'Hello world!' stdout` == 1 ]] || false
13+
[[ `grep -c 'Pipeline completed using nf-ci-test-integration plugin' stdout` == 1 ]] || false
14+

tests/plugin-registry.nf

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
#!/usr/bin/env nextflow
2+
/*
3+
* Copyright 2013-2025, Seqera Labs
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
process sayhello {
19+
debug true
20+
script:
21+
"""
22+
echo 'Hello world!'
23+
"""
24+
}
25+
26+
workflow {
27+
sayhello()
28+
}

0 commit comments

Comments
 (0)