diff --git a/lib-pairing/README.md b/lib-pairing/README.md
index 4c838d78..6b710203 100644
--- a/lib-pairing/README.md
+++ b/lib-pairing/README.md
@@ -25,7 +25,7 @@ Or when published to Maven:
```gradle
dependencies {
- implementation 'io.seqera:lib-pairing:1.0.0'
+ implementation 'io.seqera:lib-pairing:1.2.0'
}
```
diff --git a/lib-pairing/VERSION b/lib-pairing/VERSION
index 1cc5f657..26aaba0e 100644
--- a/lib-pairing/VERSION
+++ b/lib-pairing/VERSION
@@ -1 +1 @@
-1.1.0
\ No newline at end of file
+1.2.0
diff --git a/lib-pairing/src/main/groovy/io/seqera/service/pairing/PairingRecord.groovy b/lib-pairing/src/main/groovy/io/seqera/service/pairing/PairingRecord.groovy
index fcdf0aae..001061a3 100644
--- a/lib-pairing/src/main/groovy/io/seqera/service/pairing/PairingRecord.groovy
+++ b/lib-pairing/src/main/groovy/io/seqera/service/pairing/PairingRecord.groovy
@@ -33,6 +33,16 @@ import groovy.transform.ToString
* for records created before this field existed, or when the remote service
* paired without a token.
*
+ *
The optional {@code issuer} is the {@code iss} claim the remote service stamps
+ * on the tokens it signs — for Platform, its OIDC issuer. It is not
+ * derivable from {@code endpoint}: on Seqera Cloud the API is served at
+ * {@code https://api.cloud.seqera.io} while the issuer is
+ * {@code https://cloud.seqera.io/api}. It is captured so a paired service can be
+ * used as a trust anchor — resolving its key set from the issuer, and requiring
+ * that an inbound token's {@code iss} belong to a service that actually paired,
+ * rather than to a caller-supplied header. It is {@code null} for records created
+ * before this field existed, or when the remote service paired without one.
+ *
* @author Paolo Di Tommaso
*/
@Canonical
@@ -45,6 +55,7 @@ class PairingRecord {
byte[] publicKey
Instant expiration
String token
+ String issuer
boolean isExpiredAt(Instant time) {
return expiration == null || expiration.isBefore(time)
diff --git a/lib-pairing/src/main/groovy/io/seqera/service/pairing/PairingService.groovy b/lib-pairing/src/main/groovy/io/seqera/service/pairing/PairingService.groovy
index 2bc5cdc3..2e881c48 100644
--- a/lib-pairing/src/main/groovy/io/seqera/service/pairing/PairingService.groovy
+++ b/lib-pairing/src/main/groovy/io/seqera/service/pairing/PairingService.groovy
@@ -68,6 +68,24 @@ interface PairingService {
*/
PairingResponse acquirePairingKey(String service, String endpoint, String token)
+ /**
+ * Generates and returns a key pair for the provided {@code service} available
+ * at {@code endpoint}, recording both the license {@code token} and the
+ * {@code issuer} the remote service presented when opening the pairing session.
+ *
+ * The key-pair is generated only if it is not already available for
+ * (service,endpoint); otherwise the current key is returned. Both {@code token}
+ * and {@code issuer} on the stored {@link PairingRecord} are kept up to date so a
+ * later rotation is reflected without waiting for the record to expire.
+ *
+ * @param service The service name
+ * @param endpoint The endpoint of the service
+ * @param token The license token presented at pairing time (may be {@code null})
+ * @param issuer The {@code iss} the remote service stamps on its tokens (may be {@code null})
+ * @return {@link PairingResponse} with the generated encoded public key
+ */
+ PairingResponse acquirePairingKey(String service, String endpoint, String token, String issuer)
+
/**
* Get the {@link PairingRecord} associated with {@code service} and {@code endpoint}
* generated with {@link #getPairingRecord(java.lang.String, java.lang.String)}
diff --git a/lib-pairing/src/main/groovy/io/seqera/service/pairing/PairingServiceImpl.groovy b/lib-pairing/src/main/groovy/io/seqera/service/pairing/PairingServiceImpl.groovy
index ae0d1147..99b41f3c 100644
--- a/lib-pairing/src/main/groovy/io/seqera/service/pairing/PairingServiceImpl.groovy
+++ b/lib-pairing/src/main/groovy/io/seqera/service/pairing/PairingServiceImpl.groovy
@@ -49,11 +49,16 @@ class PairingServiceImpl implements PairingService {
@Override
PairingResponse acquirePairingKey(String service, String endpoint) {
- return acquirePairingKey(service, endpoint, null)
+ return acquirePairingKey(service, endpoint, null, null)
}
@Override
PairingResponse acquirePairingKey(String service, String endpoint, String token) {
+ return acquirePairingKey(service, endpoint, token, null)
+ }
+
+ @Override
+ PairingResponse acquirePairingKey(String service, String endpoint, String token, String issuer) {
final key = makeKey(service,endpoint)
def entry = store.get(key)
@@ -62,17 +67,26 @@ class PairingServiceImpl implements PairingService {
log.debug "Pairing with service '${service}' at address $endpoint - pairing id: $pairingId (key: $key)"
final keyPair = generate()
final expiration = Instant.now() + config.keyLease
- final newEntry = new PairingRecord(service, endpoint, pairingId, keyPair.getPrivate().getEncoded(), keyPair.getPublic().getEncoded(), expiration, token)
+ final newEntry = new PairingRecord(service, endpoint, pairingId, keyPair.getPrivate().getEncoded(), keyPair.getPublic().getEncoded(), expiration, token, issuer)
store.put(key,newEntry)
entry = newEntry
} else {
log.trace "Paired already with service '${service}' at address $endpoint - pairing id: $entry.pairingId (key: $key)"
- // refresh the license token on the existing record so a token rotation
- // is reflected without waiting for the record to expire
+ // refresh the license token and issuer on the existing record so a rotation
+ // is reflected without waiting for the record to expire. Written in one
+ // store.put so a record cannot be persisted with only half the update.
+ boolean changed = false
if (token != null && token != entry.token) {
entry.token = token
- store.put(key, entry)
+ changed = true
+ }
+ if (issuer != null && issuer != entry.issuer) {
+ log.debug "Updating pairing issuer for service '${service}' at address $endpoint - issuer: $issuer (key: $key)"
+ entry.issuer = issuer
+ changed = true
}
+ if (changed)
+ store.put(key, entry)
}
return new PairingResponse( pairingId: entry.pairingId, publicKey: entry.publicKey.encodeBase64() )
diff --git a/lib-pairing/src/main/groovy/io/seqera/service/pairing/socket/PairingWebSocket.groovy b/lib-pairing/src/main/groovy/io/seqera/service/pairing/socket/PairingWebSocket.groovy
index 2ea69458..e40214d0 100644
--- a/lib-pairing/src/main/groovy/io/seqera/service/pairing/socket/PairingWebSocket.groovy
+++ b/lib-pairing/src/main/groovy/io/seqera/service/pairing/socket/PairingWebSocket.groovy
@@ -50,7 +50,7 @@ import static io.seqera.random.LongRndKey.rndHex
@CompileStatic
@Singleton
@ExecuteOn(TaskExecutors.BLOCKING)
-@ServerWebSocket("/pairing/{service}/token/{token}{?endpoint}")
+@ServerWebSocket("/pairing/{service}/token/{token}{?endpoint,issuer}")
class PairingWebSocket {
@Inject
@@ -66,9 +66,15 @@ class PairingWebSocket {
@Nullable
private LicenseValidator licenseValidator
+ /**
+ * @param issuer The {@code iss} the remote service stamps on the tokens it signs, sent as an
+ * optional query param. {@link Nullable} deliberately: a client that predates the param
+ * omits it, and binding would otherwise fail for every existing caller. It cannot be
+ * derived from {@code endpoint} — on Seqera Cloud the API host and the issuer differ.
+ */
@OnOpen
- void onOpen(String service, String token, String endpoint, WebSocketSession session) {
- log.debug "Opening pairing session - endpoint: ${endpoint} [sessionId: $session.id]"
+ void onOpen(String service, String token, String endpoint, @Nullable String issuer, WebSocketSession session) {
+ log.debug "Opening pairing session - endpoint: ${endpoint}; issuer: ${issuer} [sessionId: $session.id]"
if( isDenyHost(endpoint) ) {
log.warn "Pairing not allowed for endpoint: ${endpoint}"
@@ -93,8 +99,9 @@ class PairingWebSocket {
// acquire a pairing key and send it to the remote client, recording the
// license token the remote service presented so requests declaring this
- // endpoint can later be validated against the license binding
- final resp = this.pairingService.acquirePairingKey(service, endpoint, token)
+ // endpoint can later be validated against the license binding, and its
+ // token issuer so the paired service can be used as a trust anchor
+ final resp = this.pairingService.acquirePairingKey(service, endpoint, token, issuer)
final msg = new PairingResponse(
msgId: rndHex(),
pairingId: resp.pairingId,
diff --git a/lib-pairing/src/test/groovy/io/seqera/service/pairing/PairingRecordSerializationTest.groovy b/lib-pairing/src/test/groovy/io/seqera/service/pairing/PairingRecordSerializationTest.groovy
index 864996e6..27f01b43 100644
--- a/lib-pairing/src/test/groovy/io/seqera/service/pairing/PairingRecordSerializationTest.groovy
+++ b/lib-pairing/src/test/groovy/io/seqera/service/pairing/PairingRecordSerializationTest.groovy
@@ -70,6 +70,50 @@ class PairingRecordSerializationTest extends Specification {
decoded.expiration == null
}
+ def 'should serialize and deserialize the token issuer'() {
+ given:
+ def encoder = new MoshiEncodeStrategy() {}
+ def record = new PairingRecord(
+ 'tower',
+ 'https://api.cloud.seqera.io',
+ 'pairing-123',
+ 'private-key-data'.bytes,
+ 'public-key-data'.bytes,
+ Instant.parse('2025-06-15T10:30:00Z'),
+ 'checksum-abc',
+ 'https://cloud.seqera.io/api'
+ )
+
+ when:
+ def json = encoder.encode(record)
+ def decoded = encoder.decode(json)
+
+ then: 'the issuer round-trips, and is distinct from the endpoint'
+ decoded.issuer == 'https://cloud.seqera.io/api'
+ decoded.endpoint == 'https://api.cloud.seqera.io'
+ decoded.issuer != decoded.endpoint
+ }
+
+ def 'should decode a record written before the issuer field existed'() {
+ given: 'a record encoded WITH an issuer, then stripped of it — the shape a pre-issuer release wrote'
+ def encoder = new MoshiEncodeStrategy() {}
+ def full = encoder.encode(new PairingRecord(
+ 'tower', 'https://tower.example.com', 'pairing-123',
+ 'private-key-data'.bytes, 'public-key-data'.bytes,
+ Instant.parse('2025-06-15T10:30:00Z'), 'checksum-abc', 'https://tower.example.com/api'))
+ // derived rather than hand-written so the byte[] encoding stays whatever Moshi actually uses
+ def json = full.replaceAll(/,?"issuer":"[^"]*"/, '')
+ assert !json.contains('issuer')
+
+ when:
+ def decoded = encoder.decode(json)
+
+ then: 'it still decodes, with a null issuer rather than an error'
+ decoded.service == 'tower'
+ decoded.token == 'checksum-abc'
+ decoded.issuer == null
+ }
+
def 'should serialize and deserialize the license token'() {
given:
def encoder = new MoshiEncodeStrategy() {}
diff --git a/lib-pairing/src/test/groovy/io/seqera/service/pairing/PairingServiceIssuerTest.groovy b/lib-pairing/src/test/groovy/io/seqera/service/pairing/PairingServiceIssuerTest.groovy
new file mode 100644
index 00000000..4ed856f2
--- /dev/null
+++ b/lib-pairing/src/test/groovy/io/seqera/service/pairing/PairingServiceIssuerTest.groovy
@@ -0,0 +1,115 @@
+/*
+ * Copyright 2026, Seqera Labs
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package io.seqera.service.pairing
+
+import java.time.Duration
+
+import spock.lang.Specification
+
+import io.seqera.data.store.state.impl.LocalStateProvider
+
+/**
+ * Tests that the token issuer presented at pairing time is recorded on the
+ * {@link PairingRecord} and refreshed on re-pair.
+ */
+class PairingServiceIssuerTest extends Specification {
+
+ static final String SERVICE = PairingService.TOWER_SERVICE
+ static final String ENDPOINT = 'https://api.cloud.seqera.io'
+ static final String ISSUER = 'https://cloud.seqera.io/api'
+
+ /**
+ * A real {@link PairingStore} over the in-memory {@link LocalStateProvider}, rather than a
+ * Spock mock: {@code PairingStore} is a concrete class, and class proxying via cglib fails
+ * on this JDK ("Unsupported class file major version"). Using the real store also exercises
+ * the Moshi round-trip on every put/get, so a field the encoder cannot handle would surface
+ * here rather than only in production.
+ */
+ PairingStore store = new PairingStore(new LocalStateProvider()).tap { it.config = new StubConfig() }
+ PairingServiceImpl service = new PairingServiceImpl(store: store, config: new StubConfig())
+
+ def 'should record the issuer presented at pairing time'() {
+ when:
+ service.acquirePairingKey(SERVICE, ENDPOINT, 'lic-1', ISSUER)
+
+ then:
+ with(service.getPairingRecord(SERVICE, ENDPOINT)) {
+ issuer == ISSUER
+ token == 'lic-1'
+ endpoint == ENDPOINT
+ and: 'the issuer is not the endpoint — on Cloud these genuinely differ'
+ issuer != endpoint
+ }
+ }
+
+ def 'should leave the issuer null when the client does not send one'() {
+ when: 'a client predating the param pairs through the 3-arg overload'
+ service.acquirePairingKey(SERVICE, ENDPOINT, 'lic-1')
+
+ then:
+ service.getPairingRecord(SERVICE, ENDPOINT).issuer == null
+ }
+
+ def 'should refresh the issuer on re-pair without waiting for expiry'() {
+ given:
+ service.acquirePairingKey(SERVICE, ENDPOINT, 'lic-1', ISSUER)
+ final pairingId = service.getPairingRecord(SERVICE, ENDPOINT).pairingId
+
+ when: 'the same service re-pairs announcing a different issuer'
+ service.acquirePairingKey(SERVICE, ENDPOINT, 'lic-1', 'https://cloud.eu.seqera.io/api')
+ final after = service.getPairingRecord(SERVICE, ENDPOINT)
+
+ then: 'the issuer is updated and the key pair is NOT regenerated'
+ after.issuer == 'https://cloud.eu.seqera.io/api'
+ after.pairingId == pairingId
+ }
+
+ def 'should not erase a recorded issuer when a later pair omits it'() {
+ given:
+ service.acquirePairingKey(SERVICE, ENDPOINT, 'lic-1', ISSUER)
+
+ when: 'a re-pair sends no issuer — e.g. a rolled-back client'
+ service.acquirePairingKey(SERVICE, ENDPOINT, 'lic-1', null)
+
+ then: 'the known issuer is retained rather than nulled out'
+ service.getPairingRecord(SERVICE, ENDPOINT).issuer == ISSUER
+ }
+
+ def 'should refresh token and issuer together'() {
+ given:
+ service.acquirePairingKey(SERVICE, ENDPOINT, 'lic-1', ISSUER)
+
+ when:
+ service.acquirePairingKey(SERVICE, ENDPOINT, 'lic-2', 'https://other.example.com/api')
+
+ then:
+ with(service.getPairingRecord(SERVICE, ENDPOINT)) {
+ token == 'lic-2'
+ issuer == 'https://other.example.com/api'
+ }
+ }
+
+ static class StubConfig implements PairingConfig {
+ @Override Duration getKeyLease() { Duration.ofDays(1) }
+ @Override Duration getKeyDuration() { Duration.ofDays(30) }
+ @Override Duration getChannelTimeout() { Duration.ofSeconds(5) }
+ @Override Duration getChannelAwaitTimeout() { Duration.ofMillis(100) }
+ @Override boolean getCloseSessionOnInvalidLicenseToken() { false }
+ @Override List getDenyHosts() { [] }
+ }
+}