Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lib-pairing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
```

Expand Down
2 changes: 1 addition & 1 deletion lib-pairing/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.1.0
1.2.0
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,16 @@ import groovy.transform.ToString
* for records created before this field existed, or when the remote service
* paired without a token.
*
* <p>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 <em>not</em>
* 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 <paolo.ditommaso@gmail.com>
*/
@Canonical
Expand All @@ -45,6 +55,7 @@ class PairingRecord {
byte[] publicKey
Instant expiration
String token
String issuer

boolean isExpiredAt(Instant time) {
return expiration == null || expiration.isBefore(time)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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() )
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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}"
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,50 @@ class PairingRecordSerializationTest extends Specification {
decoded.expiration == null
}

def 'should serialize and deserialize the token issuer'() {
given:
def encoder = new MoshiEncodeStrategy<PairingRecord>() {}
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<PairingRecord>() {}
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<PairingRecord>() {}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> getDenyHosts() { [] }
}
}
Loading