Skip to content

Commit 52ef992

Browse files
stackman27rodrigombsoarescursoragent
authored
Canton Add additional providers (#797)
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. --------- Co-authored-by: Rodrigo Soares <38868277+rodrigombsoares@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent f794f9d commit 52ef992

18 files changed

Lines changed: 1225 additions & 111 deletions

File tree

.changeset/canton-chain-support.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"chainlink-deployments-framework": minor
3+
---
4+
5+
Extend Canton chain authentication with OAuth2 client credentials (CI) and authorization code (local dev) providers, using RFC 8414 metadata discovery aligned with chainlink-canton authentication packages.
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
// Package authorizationcode provides OAuth2 authorization code flow authentication for Canton gRPC connections.
2+
// This flow is intended for local development where a browser-based login is available; it is not suitable for CI.
3+
package authorizationcode
4+
5+
import (
6+
"context"
7+
"crypto/tls"
8+
"errors"
9+
"fmt"
10+
"net"
11+
"net/http"
12+
"net/url"
13+
"os"
14+
"os/exec"
15+
"runtime"
16+
"slices"
17+
"sync"
18+
"time"
19+
20+
"golang.org/x/oauth2"
21+
"google.golang.org/grpc/credentials"
22+
"google.golang.org/grpc/credentials/oauth"
23+
24+
cantonauth "github.com/smartcontractkit/chainlink-deployments-framework/chain/canton/provider/authentication"
25+
)
26+
27+
var _ cantonauth.Provider = Provider{}
28+
29+
// Provider implements authentication.Provider using the OAuth2 authorization code flow with PKCE (S256).
30+
type Provider struct {
31+
tokenSource oauth.TokenSource
32+
transportCredentials credentials.TransportCredentials
33+
}
34+
35+
type authorizationCodeProviderConfig struct {
36+
scopes []string
37+
transportCredentials credentials.TransportCredentials
38+
callbackURL string
39+
openBrowser bool
40+
timeout time.Duration
41+
}
42+
43+
func defaultAuthorizationCodeProviderConfig() *authorizationCodeProviderConfig {
44+
return &authorizationCodeProviderConfig{
45+
scopes: []string{"openid", "daml_ledger_api"},
46+
transportCredentials: credentials.NewTLS(&tls.Config{
47+
MinVersion: tls.VersionTLS12,
48+
}),
49+
callbackURL: "http://127.0.0.1:8400/callback",
50+
openBrowser: true,
51+
}
52+
}
53+
54+
// ProviderOption configures the authorization code Provider.
55+
type ProviderOption func(*authorizationCodeProviderConfig)
56+
57+
// WithScopes configures the scopes requested from the authorization server.
58+
func WithScopes(scopes ...string) ProviderOption {
59+
return func(config *authorizationCodeProviderConfig) {
60+
config.scopes = scopes
61+
}
62+
}
63+
64+
// WithTransportCredentials configures transport credentials for gRPC connections.
65+
func WithTransportCredentials(creds credentials.TransportCredentials) ProviderOption {
66+
return func(config *authorizationCodeProviderConfig) {
67+
config.transportCredentials = creds
68+
}
69+
}
70+
71+
// WithCallbackURL configures the local redirect URI used by the authorization server.
72+
func WithCallbackURL(callbackURL string) ProviderOption {
73+
return func(config *authorizationCodeProviderConfig) {
74+
config.callbackURL = callbackURL
75+
}
76+
}
77+
78+
// WithOpenBrowser controls whether the default browser is opened automatically.
79+
func WithOpenBrowser(openBrowser bool) ProviderOption {
80+
return func(config *authorizationCodeProviderConfig) {
81+
config.openBrowser = openBrowser
82+
}
83+
}
84+
85+
// WithTimeout configures a timeout for the overall authorization flow.
86+
func WithTimeout(timeout time.Duration) ProviderOption {
87+
return func(config *authorizationCodeProviderConfig) {
88+
config.timeout = timeout
89+
}
90+
}
91+
92+
// NewDiscoveryProvider creates a provider using OAuth2 Authorization Server Metadata discovery (RFC 8414).
93+
// PKCE with the S256 challenge method is required.
94+
func NewDiscoveryProvider(
95+
ctx context.Context,
96+
authorizationServerURL, clientID string,
97+
options ...ProviderOption,
98+
) (*Provider, error) {
99+
metadata, err := cantonauth.GetAuthorizationServerMetadata(ctx, authorizationServerURL)
100+
if err != nil {
101+
return nil, fmt.Errorf("failed to get authorization server metadata: %w", err)
102+
}
103+
104+
if !slices.Contains(metadata.CodeChallengeMethodsSupported, "S256") {
105+
return nil, errors.New("authorization server does not support S256 PKCE challenges")
106+
}
107+
108+
return NewProvider(ctx, metadata.AuthorizationEndpoint, metadata.TokenEndpoint, clientID, options...)
109+
}
110+
111+
// NewProvider creates a provider that performs the OAuth2 authorization code flow with PKCE (S256).
112+
func NewProvider(
113+
ctx context.Context,
114+
authURL, tokenURL, clientID string,
115+
options ...ProviderOption,
116+
) (*Provider, error) {
117+
cfg := defaultAuthorizationCodeProviderConfig()
118+
for _, option := range options {
119+
option(cfg)
120+
}
121+
122+
if authURL == "" {
123+
return nil, errors.New("authURL cannot be empty")
124+
}
125+
if tokenURL == "" {
126+
return nil, errors.New("tokenURL cannot be empty")
127+
}
128+
if clientID == "" {
129+
return nil, errors.New("clientID cannot be empty")
130+
}
131+
132+
flowCtx := ctx
133+
if cfg.timeout > 0 {
134+
var cancel context.CancelFunc
135+
flowCtx, cancel = context.WithTimeout(ctx, cfg.timeout)
136+
defer cancel()
137+
}
138+
139+
callbackURL, err := url.Parse(cfg.callbackURL)
140+
if err != nil {
141+
return nil, fmt.Errorf("failed to parse callback URL: %w", err)
142+
}
143+
144+
oauthCfg := &oauth2.Config{
145+
ClientID: clientID,
146+
RedirectURL: callbackURL.String(),
147+
Scopes: cfg.scopes,
148+
Endpoint: oauth2.Endpoint{AuthURL: authURL, TokenURL: tokenURL},
149+
}
150+
151+
state := oauth2.GenerateVerifier()
152+
verifier := oauth2.GenerateVerifier()
153+
authCodeURL := oauthCfg.AuthCodeURL(state, oauth2.S256ChallengeOption(verifier))
154+
155+
callbackChan := make(chan *oauth2.Token, 1)
156+
var deliverOnce sync.Once
157+
158+
serveMux := http.NewServeMux()
159+
serveMux.HandleFunc(callbackURL.Path, func(w http.ResponseWriter, r *http.Request) {
160+
q := r.URL.Query()
161+
code := q.Get("code")
162+
receivedState := q.Get("state")
163+
164+
if receivedState != state {
165+
http.Error(w, "Invalid state parameter", http.StatusBadRequest)
166+
return
167+
}
168+
if code == "" {
169+
http.Error(w, "No code parameter received", http.StatusBadRequest)
170+
return
171+
}
172+
173+
token, exchangeErr := oauthCfg.Exchange(flowCtx, code, oauth2.VerifierOption(verifier))
174+
if exchangeErr != nil {
175+
fmt.Fprintf(os.Stderr, "authorization code token exchange failed: %v\n", exchangeErr)
176+
http.Error(w, fmt.Sprintf("Token exchange failed: %v", exchangeErr), http.StatusInternalServerError)
177+
178+
return
179+
}
180+
181+
deliverOnce.Do(func() {
182+
callbackChan <- token
183+
})
184+
185+
html := `<!DOCTYPE html>
186+
<html>
187+
<head><title>Authentication Complete</title></head>
188+
<body style="font-family: sans-serif; text-align: center; padding: 40px;">
189+
<h1>Authentication complete!</h1>
190+
<p>You can safely close this window.</p>
191+
</body>
192+
</html>
193+
`
194+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
195+
w.WriteHeader(http.StatusOK)
196+
_, _ = w.Write([]byte(html))
197+
})
198+
199+
server := http.Server{
200+
Addr: callbackURL.Host,
201+
Handler: serveMux,
202+
ReadHeaderTimeout: time.Second,
203+
ReadTimeout: 5 * time.Second,
204+
WriteTimeout: 5 * time.Second,
205+
}
206+
207+
listener, err := new(net.ListenConfig).Listen(flowCtx, "tcp", server.Addr)
208+
if err != nil {
209+
return nil, fmt.Errorf("creating listener: %w", err)
210+
}
211+
212+
serverErr := make(chan error, 1)
213+
go func() {
214+
serverErr <- server.Serve(listener)
215+
}()
216+
217+
if cfg.openBrowser {
218+
fmt.Println("Attempting to open your default browser.")
219+
fmt.Println("If the browser does not open, visit the following URL:")
220+
fmt.Println(authCodeURL)
221+
openBrowser(flowCtx, authCodeURL)
222+
} else {
223+
fmt.Println("Visit the following URL:")
224+
fmt.Println(authCodeURL)
225+
}
226+
227+
shutdown := func() {
228+
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
229+
defer cancel()
230+
_ = server.Shutdown(shutdownCtx)
231+
}
232+
233+
select {
234+
case err := <-serverErr:
235+
shutdown()
236+
return nil, fmt.Errorf("callback server error: %w", err)
237+
case token := <-callbackChan:
238+
shutdown()
239+
refreshCtx := context.WithoutCancel(ctx)
240+
tokenSource := oauthCfg.TokenSource(refreshCtx, token)
241+
242+
return &Provider{
243+
tokenSource: oauth.TokenSource{TokenSource: tokenSource},
244+
transportCredentials: cfg.transportCredentials,
245+
}, nil
246+
case <-flowCtx.Done():
247+
shutdown()
248+
return nil, flowCtx.Err()
249+
}
250+
}
251+
252+
func (p Provider) TokenSource() oauth2.TokenSource {
253+
return p.tokenSource.TokenSource
254+
}
255+
256+
func (p Provider) TransportCredentials() credentials.TransportCredentials {
257+
return p.transportCredentials
258+
}
259+
260+
func (p Provider) PerRPCCredentials() credentials.PerRPCCredentials {
261+
return p.tokenSource
262+
}
263+
264+
func openBrowser(ctx context.Context, targetURL string) {
265+
switch runtime.GOOS {
266+
case "darwin":
267+
_ = exec.CommandContext(ctx, "open", targetURL).Start()
268+
case "linux":
269+
_ = exec.CommandContext(ctx, "xdg-open", targetURL).Start()
270+
case "windows":
271+
_ = exec.CommandContext(ctx, "rundll32", "url.dll,FileProtocolHandler", targetURL).Start()
272+
}
273+
}

0 commit comments

Comments
 (0)