Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/canton-chain-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"chainlink-deployments-framework": minor
---

Add Canton as a supported chain: config (static, client_credentials, authorization_code auth), chain loader in CLD engine, and OAuth providers for CI and local use.
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changeset message says "Add Canton as a supported chain", but this PR appears to extend Canton auth (OAuth providers / additional config) rather than introducing initial Canton support. Please update the changeset summary to reflect the actual change so release notes aren’t misleading.

Copilot uses AI. Check for mistakes.
175 changes: 175 additions & 0 deletions chain/canton/provider/authentication/oauth.go
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where do you expect to use this authentication?

I see you there is code to open a browser to confirm, but this won't work in CI

Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
package authentication
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please write tests for this package


import (
"context"
"crypto/rand"
"crypto/tls"
"encoding/base64"
"fmt"
"net"
"net/http"
"os/exec"
"runtime"
"strconv"
"time"

"golang.org/x/oauth2"
"golang.org/x/oauth2/clientcredentials"
"google.golang.org/grpc/credentials"
)

var _ Provider = (*OIDCProvider)(nil)

// OIDCProvider implements Provider using OAuth2/OIDC token flows (client credentials or authorization code).
type OIDCProvider struct {
tokenSource oauth2.TokenSource
}
Comment on lines +22 to +26
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This introduces OIDCProvider but there are no unit tests covering it (unlike the existing StaticProvider tests). Add focused tests for TransportCredentials/PerRPCCredentials behavior and token acquisition (e.g., via an httptest token endpoint) to prevent regressions.

Copilot uses AI. Check for mistakes.

// NewClientCredentialsProvider creates a provider that fetches tokens using the OAuth2 client credentials flow.
// Use in CI where ClientID, ClientSecret and AuthURL are available; tokens are obtained automatically.
func NewClientCredentialsProvider(ctx context.Context, authURL, clientID, clientSecret string) (*OIDCProvider, error) {
tokenURL := authURL + "/v1/token"

oauthCfg := &clientcredentials.Config{
ClientID: clientID,
Comment on lines +30 to +34
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

authURL is concatenated directly into token/authorize endpoints and can be configured as plain http://. Since client_secret and auth codes may be transmitted, consider validating that authURL uses https (or explicitly document/guard any non-TLS local dev exception) to avoid accidental cleartext credential exchange.

Copilot uses AI. Check for mistakes.
ClientSecret: clientSecret,
TokenURL: tokenURL,
Scopes: []string{"daml_ledger_api"},
}

tokenSource := oauthCfg.TokenSource(ctx)

return &OIDCProvider{
tokenSource: tokenSource,
}, nil
}

// NewAuthorizationCodeProvider creates a provider that uses the OAuth2 authorization code flow with PKCE.
// It starts a local callback server, opens the browser to the auth URL, and exchanges the code for a token.
// Use locally to skip canton-login; only ClientID and AuthURL are required.
func NewAuthorizationCodeProvider(ctx context.Context, authURL, clientID string) (*OIDCProvider, error) {
verifier := oauth2.GenerateVerifier()

port := 8400
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the port be hardcoded here?

authEndpoint := authURL + "/v1/authorize"
tokenEndpoint := authURL + "/v1/token"
redirectURL := "http://localhost:" + strconv.Itoa(port)

Comment on lines +53 to +57
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The OAuth callback port is hard-coded to 8400. This can fail on machines where the port is already in use and prevents running multiple instances concurrently. Consider listening on 127.0.0.1:0 (ephemeral port) and deriving the RedirectURL from the actual listener address, or make the port configurable.

Copilot uses AI. Check for mistakes.
oauthCfg := &oauth2.Config{
ClientID: clientID,
RedirectURL: redirectURL + "/callback",
Scopes: []string{"openid", "daml_ledger_api"},
Endpoint: oauth2.Endpoint{AuthURL: authEndpoint, TokenURL: tokenEndpoint},
}

state := generateState()
authCodeURL := oauthCfg.AuthCodeURL(state, oauth2.S256ChallengeOption(verifier))

callbackChan := make(chan *oauth2.Token)

serveMux := http.NewServeMux()
serveMux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
code := q.Get("code")
receivedState := q.Get("state")

if receivedState != state {
http.Error(w, "Invalid state parameter", http.StatusBadRequest)
return
}

token, err := oauthCfg.Exchange(ctx, code, oauth2.VerifierOption(verifier))
if err != nil {
http.Error(w, "Token exchange failed: "+err.Error(), http.StatusInternalServerError)
return
}

callbackChan <- token
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The callback handler does a blocking send on callbackChan. If the outer function returns early (e.g., ctx canceled), a late callback request can block the handler forever waiting for a receiver, which can also stall server.Shutdown. Make the channel buffered (size 1) and/or make the send non-blocking/select on ctx.

Copilot uses AI. Check for mistakes.

html := `<!DOCTYPE html>
<html>
<head><title>Authentication Complete</title></head>
<body style="font-family: sans-serif; text-align: center; padding: 40px;">
<h1>Authentication complete!</h1>
<p>You can safely close this window.</p>
</body>
</html>
`
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(html))
})

server := http.Server{
Addr: ":" + strconv.Itoa(port),
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NewAuthorizationCodeProvider binds the callback server to ":" (all interfaces). Since this endpoint handles OAuth redirects, it should bind to loopback only (e.g., 127.0.0.1) and the RedirectURL should match, to avoid exposing the callback handler on the network.

Suggested change
Addr: ":" + strconv.Itoa(port),
Addr: "127.0.0.1:" + strconv.Itoa(port),

Copilot uses AI. Check for mistakes.
Handler: serveMux,
ReadHeaderTimeout: 5 * time.Second,
}

listener, err := new(net.ListenConfig).Listen(ctx, "tcp", server.Addr)
if err != nil {
return nil, fmt.Errorf("listening on port %d: %w", port, err)
}

serverErr := make(chan error, 1)
go func() {
serverErr <- server.Serve(listener)
}()

openBrowser(ctx, authCodeURL)

select {
case err := <-serverErr:
_ = server.Shutdown(ctx)

return nil, fmt.Errorf("callback server error: %w", err)
case token := <-callbackChan:
tokenSource := oauthCfg.TokenSource(ctx, token)
_ = server.Shutdown(ctx)

return &OIDCProvider{
tokenSource: tokenSource,
}, nil
case <-ctx.Done():
_ = server.Shutdown(ctx)
Comment on lines +121 to +134
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The HTTP server shutdown is called with the already-cancelled context in the ctx.Done() case. This means Shutdown will return immediately without waiting for graceful shutdown. Consider using a separate timeout context for shutdown (e.g., context.WithTimeout(context.Background(), 5*time.Second)) to allow ongoing requests to complete before forcing server termination.

Suggested change
select {
case err := <-serverErr:
_ = server.Shutdown(ctx)
return nil, fmt.Errorf("callback server error: %w", err)
case token := <-callbackChan:
tokenSource := oauthCfg.TokenSource(ctx, token)
_ = server.Shutdown(ctx)
return &OIDCProvider{
tokenSource: tokenSource,
}, nil
case <-ctx.Done():
_ = server.Shutdown(ctx)
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shutdownCancel()
select {
case err := <-serverErr:
_ = server.Shutdown(shutdownCtx)
return nil, fmt.Errorf("callback server error: %w", err)
case token := <-callbackChan:
tokenSource := oauthCfg.TokenSource(ctx, token)
_ = server.Shutdown(shutdownCtx)
return &OIDCProvider{
tokenSource: tokenSource,
}, nil
case <-ctx.Done():
_ = server.Shutdown(shutdownCtx)

Copilot uses AI. Check for mistakes.

return nil, ctx.Err()
}
}

func (p *OIDCProvider) TokenSource() oauth2.TokenSource {
return p.tokenSource
}

func (p *OIDCProvider) TransportCredentials() credentials.TransportCredentials {
return credentials.NewTLS(&tls.Config{
MinVersion: tls.VersionTLS12,
})
}

func (p *OIDCProvider) PerRPCCredentials() credentials.PerRPCCredentials {
return secureTokenSource{
TokenSource: p.tokenSource,
}
}

func generateState() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
panic(err)
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

generateState panics if rand.Read fails. Since this is library code used by the chain loader, prefer returning an error from generateState (and from NewAuthorizationCodeProvider) so callers can handle failures gracefully instead of crashing the process.

Suggested change
panic(err)
// Fallback to a deterministic value instead of panicking to avoid crashing callers.
return strconv.FormatInt(time.Now().UnixNano(), 10)

Copilot uses AI. Check for mistakes.
}
Comment on lines +156 to +160
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The panic in generateState on cryptographic random number generation failure could crash the application. While crypto/rand.Read failures are extremely rare in practice, consider returning an error instead of panicking to allow graceful error handling and recovery at the caller level.

Copilot uses AI. Check for mistakes.

return base64.RawURLEncoding.EncodeToString(b)
}

// openBrowser opens the default browser to url on supported platforms; otherwise it is a no-op.
func openBrowser(ctx context.Context, url string) {
switch runtime.GOOS {
case "darwin":
_ = exec.CommandContext(ctx, "open", url).Start()
case "linux":
_ = exec.CommandContext(ctx, "xdg-open", url).Start()
case "windows":
_ = exec.CommandContext(ctx, "rundll32", "url.dll,FileProtocolHandler", url).Start()
}
}
59 changes: 52 additions & 7 deletions engine/cld/chains/chains.go
Original file line number Diff line number Diff line change
Expand Up @@ -217,10 +217,10 @@ func newChainLoaders(
lggr.Info("Skipping Ton chains, no private key found in secrets")
}

if cfg.Canton.JWTToken != "" {
if cantonAuthConfigured(cfg.Canton) {
loaders[chainsel.FamilyCanton] = newChainLoaderCanton(networks, cfg)
} else {
lggr.Info("Skipping Canton chains, no JWT token found in secrets")
lggr.Info("Skipping Canton chains, no Canton auth configured (set auth_type and jwt_token, or auth_url+client_id for OAuth)")
}

return loaders
Expand Down Expand Up @@ -747,13 +747,11 @@ func (l *chainLoaderCanton) Load(ctx context.Context, selector uint64) (fchain.B
return nil, fmt.Errorf("canton network %d: no participants found in metadata", selector)
}

if l.cfg.Canton.JWTToken == "" {
return nil, fmt.Errorf("canton network %d: JWT token is required", selector)
authProvider, err := l.cantonAuthProvider(ctx, selector)
if err != nil {
return nil, err
}

// Use TLS-enforcing auth provider for Canton participant endpoints.
authProvider := cantonauth.NewStaticProvider(l.cfg.Canton.JWTToken)

participants := make([]cantonprov.ParticipantConfig, len(md.Participants))
for i, participantMD := range md.Participants {
participants[i] = cantonprov.ParticipantConfig{
Expand Down Expand Up @@ -781,6 +779,53 @@ func (l *chainLoaderCanton) Load(ctx context.Context, selector uint64) (fchain.B
return c, nil
}

// cantonAuthConfigured returns true if Canton auth is configured for at least one scheme (static, client_credentials, or authorization_code).
func cantonAuthConfigured(c cfgenv.CantonConfig) bool {
switch c.AuthType {
case cfgenv.CantonAuthTypeClientCredentials:
return c.AuthURL != "" && c.ClientID != "" && c.ClientSecret != ""
case cfgenv.CantonAuthTypeAuthorizationCode:
return c.AuthURL != "" && c.ClientID != ""
default:
// static or empty (backward compat: jwt_token alone enables Canton)
return c.JWTToken != ""
}
}

// cantonAuthProvider builds a Canton auth Provider from config. Caller must ensure cantonAuthConfigured(cfg.Canton) is true.
func (l *chainLoaderCanton) cantonAuthProvider(ctx context.Context, selector uint64) (cantonauth.Provider, error) {
c := l.cfg.Canton
switch c.AuthType {
case cfgenv.CantonAuthTypeClientCredentials:
if c.AuthURL == "" || c.ClientID == "" || c.ClientSecret == "" {
return nil, fmt.Errorf("canton network %d: client_credentials requires auth_url, client_id, and client_secret", selector)
}
oidc, err := cantonauth.NewClientCredentialsProvider(ctx, c.AuthURL, c.ClientID, c.ClientSecret)
if err != nil {
return nil, fmt.Errorf("canton network %d: client_credentials auth: %w", selector, err)
}

return oidc, nil
case cfgenv.CantonAuthTypeAuthorizationCode:
if c.AuthURL == "" || c.ClientID == "" {
return nil, fmt.Errorf("canton network %d: authorization_code requires auth_url and client_id", selector)
}
Comment on lines +799 to +812
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The validation in cantonAuthProvider duplicates the checks already performed in cantonAuthConfigured. This creates a maintainability issue where validation logic must be kept in sync across two functions. Consider refactoring to reuse cantonAuthConfigured checks or returning more specific errors from cantonAuthConfigured to avoid redundant validation.

Copilot uses AI. Check for mistakes.
oidc, err := cantonauth.NewAuthorizationCodeProvider(ctx, c.AuthURL, c.ClientID)
if err != nil {
return nil, fmt.Errorf("canton network %d: authorization_code auth: %w", selector, err)
}

return oidc, nil
default:
// static or empty
if c.JWTToken == "" {
return nil, fmt.Errorf("canton network %d: JWT token is required for static auth", selector)
}

return cantonauth.NewStaticProvider(c.JWTToken), nil
}
}
Comment on lines +782 to +827
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new helper functions cantonAuthConfigured and cantonAuthProvider lack test coverage. The existing Test_chainLoaderCanton_Load test file (engine/cld/chains/chains_test.go:1200-1382) only tests the static JWT token flow and should be extended to cover the new OAuth flows. Tests should verify that cantonAuthConfigured correctly identifies each auth type configuration and that cantonAuthProvider creates the appropriate provider type. Additionally, error cases such as missing required fields for each auth type should be tested.

Copilot uses AI. Check for mistakes.
Comment on lines +782 to +827
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new cantonAuthProvider and cantonAuthConfigured helper functions lack test coverage. The existing Test_chainLoaderCanton_Load test only covers static JWT authentication and needs to be extended with test cases for the new client_credentials and authorization_code auth types to verify correct provider selection, validation, and error handling for each authentication scheme.

Copilot uses AI. Check for mistakes.

// useKMS returns true if both KeyID and KeyRegion are set in the provided KMS config.
func useKMS(kmsCfg cfgenv.KMSConfig) bool {
return kmsCfg.KeyID != "" && kmsCfg.KeyRegion != ""
Expand Down
24 changes: 21 additions & 3 deletions engine/cld/config/env/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,14 +82,28 @@ type TronConfig struct {
DeployerKey string `mapstructure:"deployer_key" yaml:"deployer_key"` // Secret: The private key of the deployer account.
}

// CantonAuthType is the authentication scheme for Canton participant APIs.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type can be an overloaded word. I'd prefer the name Strategy here

Suggested change
// CantonAuthType is the authentication scheme for Canton participant APIs.
// CantonAuthStrategy is the authentication strategy for Canton participant APIs.

const (
CantonAuthTypeStatic = "static" // Pre-obtained JWT (e.g. from canton-login).
CantonAuthTypeClientCredentials = "client_credentials" // CI: fetch token with client_id + client_secret + auth_url.
CantonAuthTypeAuthorizationCode = "authorization_code" // Local: browser flow with client_id + auth_url.
)

// CantonConfig is the configuration for the Canton Chains.
//
// WARNING: This data type contains sensitive fields and should not be logged or set in file
// configuration.
type CantonConfig struct {
// JWT token for authenticating with Canton participants. This token will be used for all participants.
// For more complex scenarios with different tokens per participant, use the network metadata.
JWTToken string `mapstructure:"jwt_token" yaml:"jwt_token"` // Secret: JWT token for Canton participant authentication.
// AuthType selects how to obtain the token: "static" (jwt_token), "client_credentials" (CI), or "authorization_code" (local browser).
AuthType string `mapstructure:"auth_type" yaml:"auth_type"`
// JWT token for static auth. Used when auth_type is "static".
JWTToken string `mapstructure:"jwt_token" yaml:"jwt_token"` // Secret
// AuthURL is the OIDC base URL (e.g. https://auth.example.com). Token URL is AuthURL/v1/token, authorize is AuthURL/v1/authorize.
AuthURL string `mapstructure:"auth_url" yaml:"auth_url"`
// ClientID is the OAuth2 client ID. Used for client_credentials and authorization_code.
ClientID string `mapstructure:"client_id" yaml:"client_id"` // Secret
// ClientSecret is the OAuth2 client secret. Required only for client_credentials (CI).
ClientSecret string `mapstructure:"client_secret" yaml:"client_secret"` // Secret
}

// JobDistributorConfig is the configuration for connecting and authenticating to the Job
Expand Down Expand Up @@ -247,7 +261,11 @@ var (
"onchain.stellar.deployer_key": {"ONCHAIN_STELLAR_DEPLOYER_KEY"},
"onchain.ton.deployer_key": {"ONCHAIN_TON_DEPLOYER_KEY", "TON_DEPLOYER_KEY"},
"onchain.ton.wallet_version": {"ONCHAIN_TON_WALLET_VERSION", "TON_WALLET_VERSION"},
"onchain.canton.auth_type": {"ONCHAIN_CANTON_AUTH_TYPE"},
"onchain.canton.jwt_token": {"ONCHAIN_CANTON_JWT_TOKEN"},
"onchain.canton.auth_url": {"ONCHAIN_CANTON_AUTH_URL"},
"onchain.canton.client_id": {"ONCHAIN_CANTON_CLIENT_ID"},
"onchain.canton.client_secret": {"ONCHAIN_CANTON_CLIENT_SECRET"},
"offchain.job_distributor.auth.cognito_app_client_id": {"OFFCHAIN_JD_AUTH_COGNITO_APP_CLIENT_ID", "JD_AUTH_COGNITO_APP_CLIENT_ID"},
"offchain.job_distributor.auth.cognito_app_client_secret": {"OFFCHAIN_JD_AUTH_COGNITO_APP_CLIENT_SECRET", "JD_AUTH_COGNITO_APP_CLIENT_SECRET"},
"offchain.job_distributor.auth.aws_region": {"OFFCHAIN_JD_AUTH_AWS_REGION", "JD_AUTH_AWS_REGION"},
Expand Down
12 changes: 10 additions & 2 deletions engine/cld/config/env/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,11 @@ var (
DeployerKey: "0x567",
},
Canton: CantonConfig{
JWTToken: "",
AuthType: "",
JWTToken: "",
AuthURL: "",
ClientID: "",
ClientSecret: "",
Comment on lines +48 to +52
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CantonConfig is always expected to be empty in these fixtures, so the tests won’t catch mistakes in the newly added Canton env var bindings (ONCHAIN_CANTON_AUTH_TYPE/AUTH_URL/CLIENT_ID/CLIENT_SECRET). Add a test case that sets these env vars (for oauth and/or static) and asserts they unmarshal into CantonConfig correctly.

Copilot uses AI. Check for mistakes.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Much like other Fields, set some data in here to assert that the values are unmarshalled correctly

},
},
Offchain: OffchainConfig{
Expand Down Expand Up @@ -166,7 +170,11 @@ var (
WalletVersion: "V5R1",
},
Canton: CantonConfig{
JWTToken: "",
AuthType: "",
JWTToken: "",
AuthURL: "",
ClientID: "",
ClientSecret: "",
},
},
Offchain: OffchainConfig{
Expand Down
4 changes: 4 additions & 0 deletions engine/cld/config/env/testdata/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,11 @@ onchain:
stellar:
deployer_key: "0x567"
canton:
auth_type: ""
jwt_token: ""
auth_url: ""
client_id: ""
client_secret: ""
offchain:
job_distributor:
endpoints:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@ onchain:
stellar:
deployer_key: "0x567"
canton:
auth_type: ""
jwt_token: ""
auth_url: ""
client_id: ""
client_secret: ""
offchain:
job_distributor:
endpoints:
Expand Down
Loading