diff --git a/.changeset/canton-chain-support.md b/.changeset/canton-chain-support.md new file mode 100644 index 000000000..7497ab4fc --- /dev/null +++ b/.changeset/canton-chain-support.md @@ -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. diff --git a/chain/canton/provider/authentication/oauth.go b/chain/canton/provider/authentication/oauth.go new file mode 100644 index 000000000..4f46d47b4 --- /dev/null +++ b/chain/canton/provider/authentication/oauth.go @@ -0,0 +1,175 @@ +package authentication + +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 +} + +// 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, + 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 + authEndpoint := authURL + "/v1/authorize" + tokenEndpoint := authURL + "/v1/token" + redirectURL := "http://localhost:" + strconv.Itoa(port) + + 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 + + html := ` + +
You can safely close this window.
+ + +` + 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), + 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) + + 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) + } + + 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() + } +} diff --git a/engine/cld/chains/chains.go b/engine/cld/chains/chains.go index 3c4860cf0..a45a36668 100644 --- a/engine/cld/chains/chains.go +++ b/engine/cld/chains/chains.go @@ -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 @@ -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{ @@ -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) + } + 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 + } +} + // 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 != "" diff --git a/engine/cld/config/env/config.go b/engine/cld/config/env/config.go index fb5d66b00..9a650ea06 100644 --- a/engine/cld/config/env/config.go +++ b/engine/cld/config/env/config.go @@ -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. +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 @@ -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"}, diff --git a/engine/cld/config/env/config_test.go b/engine/cld/config/env/config_test.go index 0d382cdc3..46a115904 100644 --- a/engine/cld/config/env/config_test.go +++ b/engine/cld/config/env/config_test.go @@ -45,7 +45,11 @@ var ( DeployerKey: "0x567", }, Canton: CantonConfig{ - JWTToken: "", + AuthType: "", + JWTToken: "", + AuthURL: "", + ClientID: "", + ClientSecret: "", }, }, Offchain: OffchainConfig{ @@ -166,7 +170,11 @@ var ( WalletVersion: "V5R1", }, Canton: CantonConfig{ - JWTToken: "", + AuthType: "", + JWTToken: "", + AuthURL: "", + ClientID: "", + ClientSecret: "", }, }, Offchain: OffchainConfig{ diff --git a/engine/cld/config/env/testdata/config.yml b/engine/cld/config/env/testdata/config.yml index 6e4da91ba..28bba081f 100644 --- a/engine/cld/config/env/testdata/config.yml +++ b/engine/cld/config/env/testdata/config.yml @@ -25,7 +25,11 @@ onchain: stellar: deployer_key: "0x567" canton: + auth_type: "" jwt_token: "" + auth_url: "" + client_id: "" + client_secret: "" offchain: job_distributor: endpoints: diff --git a/engine/cld/config/env/testdata/config_with_optional_values.yml b/engine/cld/config/env/testdata/config_with_optional_values.yml index 00e87f011..2cd84dd8a 100644 --- a/engine/cld/config/env/testdata/config_with_optional_values.yml +++ b/engine/cld/config/env/testdata/config_with_optional_values.yml @@ -20,7 +20,11 @@ onchain: stellar: deployer_key: "0x567" canton: + auth_type: "" jwt_token: "" + auth_url: "" + client_id: "" + client_secret: "" offchain: job_distributor: endpoints: