Canton Add additional providers#797
Conversation
🦋 Changeset detectedLatest commit: 1c91b20 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
👋 stackman27, thanks for creating this pull request! To help reviewers, please consider creating future PRs as drafts first. This allows you to self-review and make any final changes before notifying the team. Once you're ready, you can mark it as "Ready for review" to request feedback. Thanks! |
There was a problem hiding this comment.
Pull request overview
This pull request adds OAuth2 authentication support for Canton chains, extending the existing static JWT token authentication with two new OAuth2 flows: client credentials (for CI environments) and authorization code with PKCE (for local development).
Changes:
- Introduced three authentication types for Canton: static (existing JWT), client_credentials (OAuth2 for CI), and authorization_code (OAuth2 with browser flow for local development)
- Added new configuration fields to CantonConfig for OAuth2 credentials (AuthURL, ClientID, ClientSecret) with corresponding environment variable mappings
- Implemented OIDCProvider with support for both OAuth2 flows, including automatic token refresh capabilities
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 15 comments.
| File | Description |
|---|---|
| engine/cld/config/env/config.go | Added auth type constants and new OAuth2 configuration fields to CantonConfig with environment variable mappings |
| engine/cld/chains/chains.go | Refactored Canton chain loader to support multiple auth types with provider factory pattern and enhanced configuration validation |
| chain/canton/provider/authentication/oauth.go | New file implementing OIDCProvider with client credentials and authorization code flows, including local callback server for browser-based auth |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated 16 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // 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 | ||
| } | ||
| } |
There was a problem hiding this comment.
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.
| 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) | ||
| } |
There was a problem hiding this comment.
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.
| func generateState() string { | ||
| b := make([]byte, 16) | ||
| if _, err := rand.Read(b); err != nil { | ||
| panic(err) | ||
| } |
There was a problem hiding this comment.
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.
| 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) |
There was a problem hiding this comment.
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.
| 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) |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated 8 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| // OIDCProvider implements Provider using OAuth2/OIDC token flows (client credentials or authorization code). | ||
| type OIDCProvider struct { | ||
| tokenSource oauth2.TokenSource | ||
| } |
There was a problem hiding this comment.
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.
| AuthType: "", | ||
| JWTToken: "", | ||
| AuthURL: "", | ||
| ClientID: "", | ||
| ClientSecret: "", |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Agreed. Much like other Fields, set some data in here to assert that the values are unmarshalled correctly
There was a problem hiding this comment.
done. Added Canton env vars (ONCHAIN_CANTON_AUTH_STRATEGY, ONCHAIN_CANTON_OKTA_*, ONCHAIN_CANTON_JWT_TOKEN) to the shared envVars/envCfg fixtures and assert them via Test_Load, Test_LoadEnv, and Test_LoadEnv_Legacy
| func NewAuthorizationCodeProvider(ctx context.Context, authURL, clientID string) (*OIDCProvider, error) { | ||
| verifier := oauth2.GenerateVerifier() | ||
|
|
||
| port := 8400 |
There was a problem hiding this comment.
Should the port be hardcoded here?
There was a problem hiding this comment.
Yes, kept 8400 as the default (Okta redirect URIs must match), but made it configurable via WithCallbackURL and bound to 127.0.0.1 only.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
authorization_code is for local dev only (browser OAuth via authorizationcode package). CI uses client_credentials (clientcredentials package) or a pre-set static JWT, documented in package comments and CantonAuthStrategy constants
| @@ -0,0 +1,175 @@ | |||
| package authentication | |||
There was a problem hiding this comment.
Please write tests for this package
There was a problem hiding this comment.
Done, split into clientcredentials and authorizationcode subpackages, each with unit tests covering validation, metadata discovery, and token acquisition.
|
Please update the description to explain why this change is being introduce. This helps the reviewer with context on what is being reviewed |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated 6 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
Split oauth.go into clientcredentials and authorizationcode packages with RFC 8414 discovery, rename AuthType to AuthStrategy, and add test coverage.
Resolve go.mod conflict by taking main's dependency versions. Co-authored-by: Cursor <cursoragent@cursor.com>
Use CANTON_OKTA_AUTHORIZER, CANTON_OKTA_CLIENT_ID, and CANTON_OKTA_CLIENT_SECRET so CLDF matches the keys registered in CLD CI. Co-authored-by: Cursor <cursoragent@cursor.com>
| switch c.AuthStrategy { | ||
| case cfgenv.CantonAuthStrategyClientCredentials: | ||
| return c.AuthURL != "" && c.ClientID != "" && c.ClientSecret != "" | ||
| case cfgenv.CantonAuthStrategyAuthorizationCode: | ||
| return c.AuthURL != "" && c.ClientID != "" | ||
| default: | ||
| // static or empty (backward compat: jwt_token alone enables Canton) | ||
| return c.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_strategy and jwt_token, or auth_url+client_id for OAuth)") | ||
| } |
|
ecPablo
left a comment
There was a problem hiding this comment.
Thanks @stackman27, left some comments. I'll defer the final stamp to @jkongie and @graham-chainlink as they have more context on cldf to provide feedback on the integration
|
|
||
| if l.cfg.Canton.JWTToken == "" { | ||
| return nil, fmt.Errorf("canton network %d: JWT token is required", selector) | ||
| authProvider, err := l.cantonAuthProvider(ctx, selector, md.InsecureTransport) |
There was a problem hiding this comment.
wondering if this md.InsecureTransport should be configurable? I'm guessing in prod environments we don't want this?
There was a problem hiding this comment.
Already configurable per-network in metadata; defaults off. Only used for local plaintext gRPC with static JWT.
| case token := <-callbackChan: | ||
| shutdown() | ||
| tokenSource := oauthCfg.TokenSource(flowCtx, token) | ||
|
|
||
| return &Provider{ | ||
| tokenSource: oauth.TokenSource{TokenSource: tokenSource}, | ||
| transportCredentials: cfg.transportCredentials, | ||
| }, nil |
| switch c.AuthStrategy { | ||
| case cfgenv.CantonAuthStrategyClientCredentials: | ||
| provider, err := cantonclientcreds.NewDiscoveryProvider(ctx, c.AuthURL, c.ClientID, c.ClientSecret) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("canton network %d: client_credentials auth: %w", selector, err) | ||
| } | ||
|
|
||
| return provider, nil | ||
| case cfgenv.CantonAuthStrategyAuthorizationCode: | ||
| provider, err := cantonauthcode.NewDiscoveryProvider(ctx, c.AuthURL, c.ClientID) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("canton network %d: authorization_code auth: %w", selector, err) | ||
| } | ||
|
|
||
| return provider, nil | ||
| default: | ||
| if c.JWTToken == "" { | ||
| return nil, fmt.Errorf("canton network %d: JWT token is required for static auth", selector) | ||
| } | ||
| if insecureTransport { | ||
| return cantonauth.NewInsecureStaticProvider(c.JWTToken), nil | ||
| } | ||
|
|
||
| return cantonauth.NewStaticProvider(c.JWTToken), nil | ||
| } |


Canton chain support previously only accepted a pre-obtained static JWT via ONCHAIN_CANTON_JWT_TOKEN. That works for manual use but doesn't support automated CI pipelines or interactive local development against Okta.
This PR adds two OAuth2 authentication strategies for Canton, aligned with the chainlink-canton authentication packages:
client_credentials — for CI/CD. Fetches tokens machine-to-machine using client_id, client_secret, and the Okta authorization server URL. Set ONCHAIN_CANTON_AUTH_STRATEGY=client_credentials along with ONCHAIN_CANTON_OKTA_AUTHORIZER, ONCHAIN_CANTON_OKTA_CLIENT_ID, and ONCHAIN_CANTON_OKTA_CLIENT_SECRET.
authorization_code — for local development only. Opens a browser for interactive OAuth with PKCE. Not suitable for CI. Set ONCHAIN_CANTON_AUTH_STRATEGY=authorization_code along with ONCHAIN_CANTON_OKTA_AUTHORIZER and ONCHAIN_CANTON_OKTA_CLIENT_ID. Default callback is http://127.0.0.1:8400/callback (configurable via WithCallbackURL).
static — unchanged. Continue using ONCHAIN_CANTON_JWT_TOKEN for a pre-obtained JWT.
Both OAuth flows use RFC 8414 authorization server metadata discovery. Providers are in chain/canton/provider/authentication/clientcredentials and authorizationcode, with unit tests. The CLD chain loader selects the provider based on auth_strategy.