Skip to content
Merged
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
17 changes: 14 additions & 3 deletions cmd/login/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ func (h *handler) execute() error {

// Use spinner for the token exchange
h.spinner.Start("Exchanging authorization code...")
tokenSet, err := oauth.ExchangeAuthorizationCode(context.Background(), nil, h.environmentSet, code, h.lastPKCEVerifier)
tokenSet, err := oauth.ExchangeAuthorizationCode(context.Background(), nil, h.environmentSet, code, h.lastPKCEVerifier, "", "")
if err != nil {
h.spinner.StopAll()
h.log.Error().Err(err).Msg("code exchange failed")
Expand Down Expand Up @@ -152,7 +152,12 @@ func (h *handler) startAuthFlow() (string, error) {
return "", err
}
h.lastPKCEVerifier = verifier
h.lastState = oauth.RandomState()
state, err := oauth.RandomState()
if err != nil {
h.spinner.Stop()
return "", err
}
h.lastState = state

authURL := h.buildAuthURL(challenge, h.lastState)

Expand Down Expand Up @@ -209,7 +214,13 @@ func (h *handler) callbackHandler(codeCh chan string) http.HandlerFunc {
return
}
h.lastPKCEVerifier = verifier
h.lastState = oauth.RandomState()
st, err := oauth.RandomState()
if err != nil {
h.log.Error().Err(err).Msg("failed to generate OAuth state for retry")
oauth.ServeEmbeddedHTML(h.log, w, oauth.PageError, http.StatusInternalServerError)
return
}
h.lastState = st
h.retryCount++

// Build the new auth URL for redirect
Expand Down
10 changes: 8 additions & 2 deletions cmd/login/login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,14 @@ func TestGeneratePKCE_ReturnsValidChallenge(t *testing.T) {
}

func TestRandomState_IsRandomAndNonEmpty(t *testing.T) {
state1 := oauth.RandomState()
state2 := oauth.RandomState()
state1, err := oauth.RandomState()
if err != nil {
t.Fatalf("RandomState: %v", err)
}
state2, err := oauth.RandomState()
if err != nil {
t.Fatalf("RandomState: %v", err)
}
if state1 == "" || state2 == "" {
t.Error("randomState returned empty string")
}
Expand Down
96 changes: 78 additions & 18 deletions cmd/secrets/common/browser_flow.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ package common

import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"net/http"
rt "runtime"
"strings"
"time"

"github.com/google/uuid"
"github.com/machinebox/graphql"
Expand All @@ -19,6 +19,7 @@ import (
"github.com/smartcontractkit/cre-cli/internal/client/graphqlclient"
"github.com/smartcontractkit/cre-cli/internal/constants"
"github.com/smartcontractkit/cre-cli/internal/credentials"
"github.com/smartcontractkit/cre-cli/internal/oauth"
"github.com/smartcontractkit/cre-cli/internal/ui"
)

Expand All @@ -28,6 +29,13 @@ const createVaultAuthURLMutation = `mutation CreateVaultAuthorizationUrl($reques
}
}`

const exchangeAuthCodeToTokenMutation = `mutation ExchangeAuthCodeToToken($request: AuthCodeTokenExchangeRequest!) {
exchangeAuthCodeToToken(request: $request) {
accessToken
expiresIn
}
}`

// vaultPermissionForMethod returns the API permission name for the given vault operation.
func vaultPermissionForMethod(method string) (string, error) {
switch method {
Expand All @@ -45,7 +53,9 @@ func digestHexString(digest [32]byte) string {
}

// executeBrowserUpsert handles secrets create/update when the user signs in with their organization account.
// It encrypts the payload, binds a digest, and completes the platform authorization request for this step.
// It encrypts the payload, binds a digest, requests a platform authorization URL, completes OAuth in the browser,
// and exchanges the code via the platform for a short-lived vault JWT (for future DON gateway submission).
// Login tokens in ~/.cre/cre.yaml are not modified; that session stays separate from this vault-only token.
func (h *Handler) executeBrowserUpsert(ctx context.Context, inputs UpsertSecretsInputs, method string) error {
if h.Credentials.AuthType == credentials.AuthTypeApiKey {
return fmt.Errorf("this sign-in flow requires an interactive login; API keys are not supported")
Expand Down Expand Up @@ -105,7 +115,7 @@ func (h *Handler) executeBrowserUpsert(ctx context.Context, inputs UpsertSecrets
return err
}

_, challenge, err := generatePKCES256()
verifier, challenge, err := oauth.GeneratePKCE()
if err != nil {
return err
}
Expand All @@ -132,22 +142,72 @@ func (h *Handler) executeBrowserUpsert(ctx context.Context, inputs UpsertSecrets
if err := gqlClient.Execute(ctx, gqlReq, &gqlResp); err != nil {
return fmt.Errorf("could not complete the authorization request")
}
if gqlResp.CreateVaultAuthorizationURL.URL == "" {
authURL := gqlResp.CreateVaultAuthorizationURL.URL
if authURL == "" {
return fmt.Errorf("could not complete the authorization request")
}

ui.Success("Authorization completed successfully.")
return nil
}
platformState, _ := oauth.StateFromAuthorizeURL(authURL)

codeCh := make(chan string, 1)
server, listener, err := oauth.NewCallbackHTTPServer(constants.AuthListenAddr, oauth.SecretsCallbackHandler(codeCh, platformState, h.Log))
if err != nil {
return fmt.Errorf("could not start local callback server: %w", err)
}
defer func() {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = server.Shutdown(shutdownCtx)
}()

go func() {
if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
h.Log.Error().Err(err).Msg("secrets oauth callback server error")
}
}()

ui.Dim("Opening your browser to complete sign-in...")
if err := oauth.OpenBrowser(authURL, rt.GOOS); err != nil {
ui.Warning("Could not open browser automatically")
ui.Dim("Open this URL in your browser:")
}
ui.URL(authURL)
ui.Line()
ui.Dim("Waiting for authorization... (Press Ctrl+C to cancel)")

var code string
select {
case code = <-codeCh:
case <-time.After(500 * time.Second):
return fmt.Errorf("timeout waiting for authorization")
case <-ctx.Done():
return ctx.Err()
}

// generatePKCES256 builds the PKCE verifier and challenge used for secure authorization.
func generatePKCES256() (verifier string, challenge string, err error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", "", fmt.Errorf("pkce random: %w", err)
ui.Dim("Completing vault authorization...")
exchangeReq := graphql.NewRequest(exchangeAuthCodeToTokenMutation)
exchangeReq.Var("request", map[string]any{
"code": code,
"codeVerifier": verifier,
"redirectUri": constants.AuthRedirectURI,
})
var exchangeResp struct {
ExchangeAuthCodeToToken struct {
AccessToken string `json:"accessToken"`
ExpiresIn int `json:"expiresIn"`
} `json:"exchangeAuthCodeToToken"`
}
verifier = base64.RawURLEncoding.EncodeToString(b)
sum := sha256.Sum256([]byte(verifier))
challenge = base64.RawURLEncoding.EncodeToString(sum[:])
return verifier, challenge, nil
if err := gqlClient.Execute(ctx, exchangeReq, &exchangeResp); err != nil {
return fmt.Errorf("token exchange failed: %w", err)
}
tok := exchangeResp.ExchangeAuthCodeToToken
if tok.AccessToken == "" {
return fmt.Errorf("token exchange failed: empty access token")
}
// Short-lived vault JWT for future DON secret submission; do not persist or replace cre login tokens.
_ = tok.AccessToken
_ = tok.ExpiresIn
Comment on lines +208 to +209
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I assume there will be a follow-up where the access token is passed to the command execution?


ui.Success("Vault authorization completed.")
return nil
}
8 changes: 5 additions & 3 deletions cmd/secrets/common/browser_flow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import (
"github.com/stretchr/testify/require"

"github.com/smartcontractkit/chainlink/v2/core/capabilities/vault/vaulttypes"

"github.com/smartcontractkit/cre-cli/internal/oauth"
)

func TestVaultPermissionForMethod(t *testing.T) {
Expand All @@ -30,9 +32,9 @@ func TestDigestHexString(t *testing.T) {
assert.Equal(t, "0x0102030000000000000000000000000000000000000000000000000000000000", digestHexString(d))
}

// TestGeneratePKCES256 checks PKCE S256 (RFC 7636) used by the browser secrets authorization step.
func TestGeneratePKCES256(t *testing.T) {
verifier, challenge, err := generatePKCES256()
// TestBrowserFlowPKCE checks PKCE S256 (RFC 7636) used by the browser secrets authorization step.
func TestBrowserFlowPKCE(t *testing.T) {
verifier, challenge, err := oauth.GeneratePKCE()
require.NoError(t, err)
require.NotEmpty(t, verifier)
require.NotEmpty(t, challenge)
Expand Down
20 changes: 15 additions & 5 deletions internal/oauth/exchange.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,30 @@ import (
// DefaultHTTPClient is used for token exchange when no client is supplied.
var DefaultHTTPClient = &http.Client{Timeout: 10 * time.Second}

// ExchangeAuthorizationCode exchanges an OAuth authorization code for tokens using
// environment credentials (AuthBase, ClientID) and PKCE code_verifier.
func ExchangeAuthorizationCode(ctx context.Context, httpClient *http.Client, env *environments.EnvironmentSet, code, codeVerifier string) (*credentials.CreLoginTokenSet, error) {
// ExchangeAuthorizationCode exchanges an OAuth authorization code for tokens (PKCE).
// If oauthClientID is non-empty, it is used as client_id (must match the authorize URL).
// If oauthAuthServerBase is non-empty (scheme + host only), it is used as the token endpoint host;
// otherwise env.AuthBase is used (e.g. cre login builds the authorize URL from env).
func ExchangeAuthorizationCode(ctx context.Context, httpClient *http.Client, env *environments.EnvironmentSet, code, codeVerifier, oauthClientID, oauthAuthServerBase string) (*credentials.CreLoginTokenSet, error) {
if httpClient == nil {
httpClient = DefaultHTTPClient
}
clientID := env.ClientID
if oauthClientID != "" {
clientID = oauthClientID
}
authBase := env.AuthBase
if oauthAuthServerBase != "" {
authBase = oauthAuthServerBase
}
form := url.Values{}
form.Set("grant_type", "authorization_code")
form.Set("client_id", env.ClientID)
form.Set("client_id", clientID)
form.Set("code", code)
form.Set("redirect_uri", constants.AuthRedirectURI)
form.Set("code_verifier", codeVerifier)

req, err := http.NewRequestWithContext(ctx, http.MethodPost, env.AuthBase+constants.AuthTokenPath, strings.NewReader(form.Encode()))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, authBase+constants.AuthTokenPath, strings.NewReader(form.Encode()))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
Expand Down
27 changes: 26 additions & 1 deletion internal/oauth/exchange_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,33 @@ func TestExchangeAuthorizationCode(t *testing.T) {
ClientID: "cid",
}

tok, err := ExchangeAuthorizationCode(context.Background(), ts.Client(), env, "auth-code", "verifier")
tok, err := ExchangeAuthorizationCode(context.Background(), ts.Client(), env, "auth-code", "verifier", "", "")
require.NoError(t, err)
require.NotNil(t, tok)
assert.Equal(t, "a", tok.AccessToken)
}

func TestExchangeAuthorizationCode_OAuthOverrides(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
assert.Equal(t, "override-cid", r.Form.Get("client_id"))
_ = json.NewEncoder(w).Encode(credentials.CreLoginTokenSet{
AccessToken: "b", // #nosec G101 G117 -- test fixture
TokenType: "Bearer",
})
}))
defer ts.Close()

env := &environments.EnvironmentSet{
AuthBase: "https://wrong.example",
ClientID: "wrong",
}

tok, err := ExchangeAuthorizationCode(context.Background(), ts.Client(), env, "c", "v", "override-cid", ts.URL)
require.NoError(t, err)
assert.Equal(t, "b", tok.AccessToken)
}
63 changes: 63 additions & 0 deletions internal/oauth/htmlPages/secrets_error.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Secrets authorization failed</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="./output.css" />
</head>

<body
class="bg-background-alt flex flex-col items-center justify-center min-h-screen"
>
<div class="flex items-center gap-2">
<svg
width="32"
height="32"
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M27.4099 0H4.59008C2.05505 0 0 2.05505 0 4.59008V27.4099C0 29.945 2.05505 32 4.59008 32H27.4099C29.945 32 32 29.945 32 27.4099V4.59008C32 2.05505 29.945 0 27.4099 0Z"
fill="#0847F7"
></path>
<path
d="M16.0021 6.76172L8.00244 11.3805V20.618L16.0021 25.2368L24.0017 20.618V11.3805L16.0021 6.76172ZM20.6122 18.6615L16.0021 21.3223L11.3919 18.6615V13.3384L16.0021 10.6776L20.6122 13.3384V18.6615Z"
fill="white"
></path>
</svg>
<h1 class="text-3xl font-semibold text-gray-800">CRE</h1>
</div>
<div
class="bg-white shadow-lg rounded p-8 max-w-[520px] text-center mt-8 border border-border flex flex-col items-center gap-4"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 32 32"
fill="none"
>
<g clip-path="url(#clip0_secrets_err)">
<path
d="M16 0C17.4688 0 18.8854 0.1875 20.25 0.5625C21.6146 0.9375 22.8854 1.47917 24.0625 2.1875C25.2396 2.89583 26.3177 3.72917 27.2969 4.6875C28.276 5.64583 29.1146 6.72396 29.8125 7.92188C30.5104 9.11979 31.0469 10.3958 31.4219 11.75C31.7969 13.1042 31.9896 14.5208 32 16C32 17.4688 31.8125 18.8854 31.4375 20.25C31.0625 21.6146 30.5208 22.8854 29.8125 24.0625C29.1042 25.2396 28.2708 26.3177 27.3125 27.2969C26.3542 28.276 25.276 29.1146 24.0781 29.8125C22.8802 30.5104 21.6042 31.0469 20.25 31.4219C18.8958 31.7969 17.4792 31.9896 16 32C14.5312 32 13.1146 31.8125 11.75 31.4375C10.3854 31.0625 9.11458 30.5208 7.9375 29.8125C6.76042 29.1042 5.68229 28.2708 4.70312 27.3125C3.72396 26.3542 2.88542 25.276 2.1875 24.0781C1.48958 22.8802 0.953125 21.6042 0.578125 20.25C0.203125 18.8958 0.0104167 17.4792 0 16C0 14.5312 0.1875 13.1146 0.5625 11.75C0.9375 10.3854 1.47917 9.11458 2.1875 7.9375C2.89583 6.76042 3.72917 5.68229 4.6875 4.70312C5.64583 3.72396 6.72396 2.88542 7.92188 2.1875C9.11979 1.48958 10.3958 0.953125 11.75 0.578125C13.1042 0.203125 14.5208 0.0104167 16 0ZM18 24V20H14V24H18ZM18 18V8H14V18H18Z"
fill="#EF894F"
/>
</g>
<defs>
<clipPath id="clip0_secrets_err">
<rect width="32" height="32" fill="white" />
</clipPath>
</defs>
</svg>
<h2 class="text-2xl font-semibold text-gray-800">
Secrets authorization was unsuccessful
</h2>
<p class="mt-2 text-gray-600">
Your vault sign-in step could not be completed. Close this window and try
again from your terminal.
</p>
</div>
</body>
</html>
Loading
Loading