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() { [] } + } +}