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
7 changes: 7 additions & 0 deletions .changeset/oidc-cli-device-flow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"chainlink": minor
---

#added Added OIDC login support to the CLI via the OAuth 2.0 device authorization grant (RFC 8628). When a node is configured with `AuthenticationMethod = 'oidc'`, `chainlink admin login` (with no credentials file) now performs a browser-based device flow against the identity provider, brokered by the node. The local email/password path is unchanged and remains available as a break-glass admin.

#changed The OIDC authorization-code (operator UI) flow now always uses PKCE (RFC 7636). The OIDC `ClientSecret` is now optional: confidential clients still send it, while public clients (required for the device flow) rely on PKCE instead. Existing confidential-client deployments are unaffected.
5 changes: 4 additions & 1 deletion core/cmd/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,8 @@ func NewApp(s *Shell) *cli.App {

insecureSkipVerify := c.Bool("insecure-skip-verify")
clientOpts := ClientOpts{RemoteNodeURL: *remoteNodeURL, InsecureSkipVerify: insecureSkipVerify}
cookieAuth := NewSessionCookieAuthenticator(clientOpts, DiskCookieStore{Config: cookieJar}, s.Logger)
cookieStore := DiskCookieStore{Config: cookieJar}
cookieAuth := NewSessionCookieAuthenticator(clientOpts, cookieStore, s.Logger)
sessionRequestBuilder := NewFileSessionRequestBuilder(s.Logger)

credentialsFile := c.String("admin-credentials-file")
Expand All @@ -124,6 +125,8 @@ func NewApp(s *Shell) *cli.App {
s.HTTP = NewAuthenticatedHTTPClient(s.Logger, clientOpts, cookieAuth, sr)
s.CookieAuthenticator = cookieAuth
s.FileSessionRequestBuilder = sessionRequestBuilder
s.clientOpts = clientOpts
s.cookieStore = cookieStore

// Allow for initServerConfig to be called if the flag is provided.
if c.Bool("applyInitServerConfig") {
Expand Down
201 changes: 201 additions & 0 deletions core/cmd/oidc_device_auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
package cmd

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"

"github.com/pkg/errors"

"github.com/smartcontractkit/chainlink/v2/core/logger"
"github.com/smartcontractkit/chainlink/v2/core/web"
)

// OIDC device authorization grant client for the CLI.
//
// When a node is configured with AuthenticationMethod = 'oidc', the local
// users table only holds break-glass admins; interactive operators authenticate
// through the identity provider. The CLI cannot drive a browser redirect, so it
// uses the RFC 8628 device flow the node brokers at /oidc-device/start and
// /oidc-device/poll. The CLI never talks to the identity provider directly and
// never holds any token; it receives only an opaque handle and, on success, the
// same session cookie the browser flow produces.

// oidcDeviceStartResponse mirrors oidcauth.DeviceStartResponse.
type oidcDeviceStartResponse struct {
DeviceHandle string `json:"device_handle"`
UserCode string `json:"user_code"`
VerificationURI string `json:"verification_uri"`
VerificationURIComplete string `json:"verification_uri_complete,omitempty"`
ExpiresIn int64 `json:"expires_in"`
Interval int64 `json:"interval"`
}

// oidcDevicePollResponse mirrors oidcauth.DevicePollResponse.
type oidcDevicePollResponse struct {
Status string `json:"status"`
Message string `json:"message,omitempty"`
}

// OIDCDeviceCookieAuthenticator obtains a session cookie via the node-brokered
// device authorization flow and persists it to the same cookie store the
// password authenticator uses.
type OIDCDeviceCookieAuthenticator struct {
config ClientOpts
store CookieStore
lggr logger.SugaredLogger
// out is where user-facing prompts are written. Defaults to os.Stdout in
// production; overridable in tests.
out io.Writer
}

func NewOIDCDeviceCookieAuthenticator(config ClientOpts, store CookieStore, out io.Writer, lggr logger.Logger) *OIDCDeviceCookieAuthenticator {
return &OIDCDeviceCookieAuthenticator{
config: config,
store: store,
lggr: logger.Sugared(lggr),
out: out,
}
}

// NodeHasOIDCEnabled probes the node's unauthenticated /oidc-enabled endpoint to
// decide whether the device flow applies. A non-OIDC node 404s the route.
func NodeHasOIDCEnabled(ctx context.Context, config ClientOpts, lggr logger.Logger) bool {
client := newHttpClient(lggr, config.InsecureSkipVerify)
u := config.RemoteNodeURL.String() + "/oidc-enabled"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return false
}
resp, err := client.Do(req)
if err != nil {
return false
}
defer resp.Body.Close()
return resp.StatusCode == http.StatusOK
}

// Login runs the full device flow: start, prompt the operator, poll until the
// node reports completion, then save the returned session cookie.
func (o *OIDCDeviceCookieAuthenticator) Login(ctx context.Context) error {
start, err := o.start(ctx)
if err != nil {
return err
}

o.promptUser(start)

interval := time.Duration(start.Interval) * time.Second
if interval <= 0 {
interval = 5 * time.Second
}
deadline := time.Now().Add(time.Duration(start.ExpiresIn) * time.Second)

ticker := time.NewTicker(interval)
defer ticker.Stop()

for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
if time.Now().After(deadline) {
return errors.New("device authorization timed out before approval")
}
cookie, status, perr := o.poll(ctx, start.DeviceHandle)
if perr != nil {
return perr
}
switch status {
case "complete":
if cookie == nil {
return errors.New("node reported login complete but returned no session cookie")
}
return o.store.Save(cookie)
case "denied":
return errors.New("device authorization was denied or expired")
default: // "pending"
continue
}
}
}
}

func (o *OIDCDeviceCookieAuthenticator) start(ctx context.Context) (*oidcDeviceStartResponse, error) {
u := o.config.RemoteNodeURL.String() + "/oidc-device/start"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, nil)
if err != nil {
return nil, err
}
client := newHttpClient(o.lggr, o.config.InsecureSkipVerify)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, errors.Errorf("failed to start device authorization (status %d): %s", resp.StatusCode, string(body))
}
var sr oidcDeviceStartResponse
if err := json.Unmarshal(body, &sr); err != nil {
return nil, errors.Wrap(err, "failed to parse device authorization response")
}
return &sr, nil
}

// poll asks the node for the flow status. On "complete" the node sets the
// session cookie on the response, which is extracted and returned.
func (o *OIDCDeviceCookieAuthenticator) poll(ctx context.Context, handle string) (*http.Cookie, string, error) {
b, err := json.Marshal(map[string]string{"device_handle": handle})
if err != nil {
return nil, "", err
}
u := o.config.RemoteNodeURL.String() + "/oidc-device/poll"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(b))
if err != nil {
return nil, "", err
}
req.Header.Set("Content-Type", "application/json")
client := newHttpClient(o.lggr, o.config.InsecureSkipVerify)
resp, err := client.Do(req)
if err != nil {
return nil, "", err
}
defer resp.Body.Close()

body, _ := io.ReadAll(resp.Body)
if resp.StatusCode == http.StatusNotFound {
return nil, "denied", nil
}
if resp.StatusCode != http.StatusOK {
return nil, "", errors.Errorf("device poll failed (status %d): %s", resp.StatusCode, string(body))
}
var pr oidcDevicePollResponse
if err := json.Unmarshal(body, &pr); err != nil {
return nil, "", errors.Wrap(err, "failed to parse device poll response")
}
if pr.Status == "complete" {
return web.FindSessionCookie(resp.Cookies()), "complete", nil
}
return nil, pr.Status, nil
}

func (o *OIDCDeviceCookieAuthenticator) promptUser(start *oidcDeviceStartResponse) {
uri := start.VerificationURIComplete
if uri == "" {
uri = start.VerificationURI
}
fmt.Fprintln(o.out, "To log in, open the following URL in a browser and sign in with your identity provider:")
fmt.Fprintf(o.out, "\n %s\n\n", uri)
if start.VerificationURIComplete == "" {
fmt.Fprintf(o.out, "When prompted, enter the code: %s\n\n", start.UserCode)
} else {
fmt.Fprintf(o.out, "Verify the code shown matches: %s\n\n", start.UserCode)
}
fmt.Fprintln(o.out, "Waiting for approval...")
}
138 changes: 138 additions & 0 deletions core/cmd/oidc_device_auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package cmd_test

import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"sync/atomic"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/smartcontractkit/chainlink/v2/core/cmd"
"github.com/smartcontractkit/chainlink/v2/core/logger"
)

// mockNode stands in for an OIDC-enabled chainlink node, serving the three
// endpoints the CLI device flow touches.
type mockNode struct {
oidcEnabled bool
pollsPending int32 // number of "pending" polls to return before "complete"
denyAfter bool // if true, return "denied" instead of completing
}

func (m *mockNode) handler(t *testing.T) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/oidc-enabled", func(w http.ResponseWriter, r *http.Request) {
if !m.oidcEnabled {
w.WriteHeader(http.StatusNotFound)
return
}
w.WriteHeader(http.StatusOK)
})
mux.HandleFunc("/oidc-device/start", func(w http.ResponseWriter, r *http.Request) {
// assert (not require) inside handler goroutines: require calls Goexit
// on the wrong goroutine.
assert.Equal(t, http.MethodPost, r.Method)
_ = json.NewEncoder(w).Encode(map[string]any{
"device_handle": "test-handle",
"user_code": "WDJB-MJHT",
"verification_uri": "https://sso.example.com/activate",
"verification_uri_complete": "https://sso.example.com/activate?user_code=WDJB-MJHT",
"expires_in": 300,
"interval": 1,
})
})
mux.HandleFunc("/oidc-device/poll", func(w http.ResponseWriter, r *http.Request) {
var req map[string]string
assert.NoError(t, json.NewDecoder(r.Body).Decode(&req))
assert.Equal(t, "test-handle", req["device_handle"], "CLI must echo back the opaque handle")

if atomic.AddInt32(&m.pollsPending, -1) >= 0 {
_ = json.NewEncoder(w).Encode(map[string]string{"status": "pending"})
return
}
if m.denyAfter {
_ = json.NewEncoder(w).Encode(map[string]string{"status": "denied", "message": "expired"})
return
}
http.SetCookie(w, &http.Cookie{
Name: "clsession",
Value: "abc123",
Path: "/",
Secure: true,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
})
_ = json.NewEncoder(w).Encode(map[string]string{"status": "complete"})
})
return mux
}

func newClientOpts(t *testing.T, srv *httptest.Server) cmd.ClientOpts {
u, err := url.Parse(srv.URL)
require.NoError(t, err)
return cmd.ClientOpts{RemoteNodeURL: *u}
}

func TestNodeHasOIDCEnabled(t *testing.T) {
t.Parallel()
lggr := logger.TestLogger(t)

enabled := &mockNode{oidcEnabled: true}
srv := httptest.NewServer(enabled.handler(t))
defer srv.Close()
assert.True(t, cmd.NodeHasOIDCEnabled(context.Background(), newClientOpts(t, srv), lggr))

disabled := &mockNode{oidcEnabled: false}
srv2 := httptest.NewServer(disabled.handler(t))
defer srv2.Close()
assert.False(t, cmd.NodeHasOIDCEnabled(context.Background(), newClientOpts(t, srv2), lggr))
}

func TestOIDCDeviceLogin_Success(t *testing.T) {
t.Parallel()
node := &mockNode{oidcEnabled: true, pollsPending: 2}
srv := httptest.NewServer(node.handler(t))
defer srv.Close()

store := &cmd.MemoryCookieStore{}
var out bytes.Buffer
auth := cmd.NewOIDCDeviceCookieAuthenticator(newClientOpts(t, srv), store, &out, logger.TestLogger(t))

require.NoError(t, auth.Login(context.Background()))

// The session cookie produced by the node must land in the cookie jar.
cookie, err := store.Retrieve()
require.NoError(t, err)
require.NotNil(t, cookie)
assert.Equal(t, "clsession", cookie.Name)
assert.Equal(t, "abc123", cookie.Value)

// The operator must be shown the verification URI and code.
assert.Contains(t, out.String(), "https://sso.example.com/activate")
assert.Contains(t, out.String(), "WDJB-MJHT")
}

func TestOIDCDeviceLogin_Denied(t *testing.T) {
t.Parallel()
node := &mockNode{oidcEnabled: true, pollsPending: 1, denyAfter: true}
srv := httptest.NewServer(node.handler(t))
defer srv.Close()

store := &cmd.MemoryCookieStore{}
var out bytes.Buffer
auth := cmd.NewOIDCDeviceCookieAuthenticator(newClientOpts(t, srv), store, &out, logger.TestLogger(t))

err := auth.Login(context.Background())
require.Error(t, err)
assert.Contains(t, err.Error(), "denied")

// On denial nothing must be written to the cookie jar.
cookie, _ := store.Retrieve()
assert.Nil(t, cookie)
}
6 changes: 6 additions & 0 deletions core/cmd/shell.go
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,12 @@ type Shell struct {
ChangePasswordPrompter ChangePasswordPrompter
PasswordPrompter PasswordPrompter

// clientOpts and cookieStore are retained so RemoteLogin can construct an
// OIDC device-flow authenticator on demand, reusing the same node URL and
// cookie jar as the password authenticator.
clientOpts ClientOpts
cookieStore CookieStore

configFiles []string
configFilesIsSet bool
secretsFiles []string
Expand Down
Loading
Loading