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
5 changes: 5 additions & 0 deletions .changeset/chatty-parents-pick.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"cre-cli": patch
---

Submit oauth secrets to vault DON
45 changes: 34 additions & 11 deletions cmd/secrets/common/browser_flow.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package common
import (
"context"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
rt "runtime"
Expand Down Expand Up @@ -59,7 +60,7 @@ 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, 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).
// exchanges the code for a short-lived vault JWT, and POSTs the same JSON-RPC body to the gateway with Bearer auth.
// 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 {
Expand All @@ -78,7 +79,10 @@ func (h *Handler) executeBrowserUpsert(ctx context.Context, inputs UpsertSecrets
}
requestID := uuid.New().String()

var digest [32]byte
var (
digest [32]byte
requestBody []byte
)

switch method {
case vaulttypes.MethodSecretsCreate:
Expand All @@ -95,6 +99,10 @@ func (h *Handler) executeBrowserUpsert(ctx context.Context, inputs UpsertSecrets
if err != nil {
return fmt.Errorf("failed to calculate create digest: %w", err)
}
requestBody, err = json.Marshal(req)
if err != nil {
return fmt.Errorf("failed to marshal JSON-RPC request: %w", err)
}

case vaulttypes.MethodSecretsUpdate:
req := jsonrpc2.Request[vault.UpdateSecretsRequest]{
Expand All @@ -110,20 +118,27 @@ func (h *Handler) executeBrowserUpsert(ctx context.Context, inputs UpsertSecrets
if err != nil {
return fmt.Errorf("failed to calculate update digest: %w", err)
}
requestBody, err = json.Marshal(req)
if err != nil {
return fmt.Errorf("failed to marshal JSON-RPC request: %w", err)
}

default:
return fmt.Errorf("unsupported method %q (expected %q or %q)", method, vaulttypes.MethodSecretsCreate, vaulttypes.MethodSecretsUpdate)
}

return h.ExecuteBrowserVaultAuthorization(ctx, method, digest)
return h.ExecuteBrowserVaultAuthorization(ctx, method, digest, requestBody)
}

// ExecuteBrowserVaultAuthorization completes platform OAuth for a vault JSON-RPC digest (create/update/delete/list).
// It does not POST to the gateway; the short-lived vault JWT is for future DON submission.
func (h *Handler) ExecuteBrowserVaultAuthorization(ctx context.Context, method string, digest [32]byte) error {
// ExecuteBrowserVaultAuthorization completes platform OAuth for a vault JSON-RPC digest (create/update/delete/list),
// then POSTs the same request body to the gateway with the vault JWT in the Authorization header.
func (h *Handler) ExecuteBrowserVaultAuthorization(ctx context.Context, method string, digest [32]byte, requestBody []byte) error {
if h.Credentials.AuthType == credentials.AuthTypeApiKey {
return fmt.Errorf("this sign-in flow requires an interactive login; API keys are not supported")
}
if len(requestBody) == 0 {
return fmt.Errorf("empty vault request body")
}

perm, err := vaultPermissionForMethod(method)
if err != nil {
Expand Down Expand Up @@ -219,10 +234,18 @@ func (h *Handler) ExecuteBrowserVaultAuthorization(ctx context.Context, method s
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
return h.postVaultGatewayWithBearer(method, requestBody, tok.AccessToken)
}

ui.Success("Vault authorization completed.")
return nil
// postVaultGatewayWithBearer POSTs the digest-bound JSON-RPC body with the vault JWT and parses the gateway response.
func (h *Handler) postVaultGatewayWithBearer(method string, requestBody []byte, accessToken string) error {
ui.Dim("Submitting request to vault gateway...")
respBody, status, err := h.Gw.PostWithBearer(requestBody, accessToken)
if err != nil {
return fmt.Errorf("gateway POST failed: %w", err)
}
if status != http.StatusOK {
return fmt.Errorf("gateway returned a non-200 status code: status_code=%d, body=%s", status, respBody)
}
return h.ParseVaultGatewayResponse(method, respBody)
}
81 changes: 81 additions & 0 deletions cmd/secrets/common/browser_flow_test.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
package common

import (
"bytes"
"crypto/sha256"
"encoding/base64"
"io"
"net/http"
"os"
"strings"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -52,3 +57,79 @@ func TestBrowserFlowPKCE(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, sum[:], decoded)
}

// postVaultGatewayWithBearer is the code path used after browser OAuth token exchange; it should stay aligned
// with owner-key gateway POST + ParseVaultGatewayResponse (minus allowlist retries).

func TestPostVaultGatewayWithBearer_CreateParsesResponse(t *testing.T) {
oldStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w

var logBuf bytes.Buffer
h := newTestHandler(&logBuf)
h.Gw = &mockGatewayClient{
post: func(gotBody []byte) ([]byte, int, error) {
assert.Contains(t, string(gotBody), "jsonrpc")
return encodeRPCBodyFromPayload(buildCreatePayloadProto(t)), http.StatusOK, nil
},
}

err := h.postVaultGatewayWithBearer(vaulttypes.MethodSecretsCreate, []byte(`{"jsonrpc":"2.0","id":"1","method":"x"}`), "vault-jwt")
w.Close()
os.Stdout = oldStdout
var out strings.Builder
_, _ = io.Copy(&out, r)

require.NoError(t, err)
assert.Contains(t, out.String(), "Secret created")
}

func TestPostVaultGatewayWithBearer_ListParsesResponse(t *testing.T) {
oldStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w

h := newTestHandler(nil)
h.Gw = &mockGatewayClient{
post: func([]byte) ([]byte, int, error) {
return encodeRPCBodyFromPayload(buildListPayloadProtoSuccessWithItems(t)), http.StatusOK, nil
},
}

err := h.postVaultGatewayWithBearer(vaulttypes.MethodSecretsList, []byte(`{}`), "t")
w.Close()
os.Stdout = oldStdout
var out strings.Builder
_, _ = io.Copy(&out, r)

require.NoError(t, err)
assert.Contains(t, out.String(), "Secret identifier")
}

func TestPostVaultGatewayWithBearer_GatewayNon200(t *testing.T) {
h, _, _ := newMockHandler(t)
h.Gw = &mockGatewayClient{
post: func([]byte) ([]byte, int, error) {
return []byte(`denied`), http.StatusForbidden, nil
},
}

err := h.postVaultGatewayWithBearer(vaulttypes.MethodSecretsDelete, []byte(`{}`), "t")
require.Error(t, err)
assert.Contains(t, err.Error(), "non-200")
assert.Contains(t, err.Error(), "403")
}

func TestPostVaultGatewayWithBearer_InvalidJSONRPC(t *testing.T) {
h, _, _ := newMockHandler(t)
h.Gw = &mockGatewayClient{
post: func([]byte) ([]byte, int, error) {
return []byte(`not-json`), http.StatusOK, nil
},
}

err := h.postVaultGatewayWithBearer(vaulttypes.MethodSecretsUpdate, []byte(`{}`), "t")
require.Error(t, err)
assert.Contains(t, err.Error(), "unmarshal")
}
71 changes: 71 additions & 0 deletions cmd/secrets/common/gateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"io"
"net/http"
"strings"
"time"

"github.com/avast/retry-go/v4"
Expand All @@ -14,6 +15,8 @@ import (

type GatewayClient interface {
Post(body []byte) (respBody []byte, status int, err error)
// PostWithBearer sends the JSON-RPC body with Authorization: Bearer for the browser OAuth flow (no allowlist retries).
PostWithBearer(body []byte, bearerToken string) (respBody []byte, status int, err error)
}

type HTTPClient struct {
Expand Down Expand Up @@ -74,6 +77,48 @@ func (g *HTTPClient) Post(body []byte) ([]byte, int, error) {
return respBody, status, nil
}

func (g *HTTPClient) PostWithBearer(body []byte, bearerToken string) ([]byte, int, error) {
if strings.TrimSpace(bearerToken) == "" {
return nil, 0, fmt.Errorf("empty bearer token")
}
attempts := g.RetryAttempts
if attempts == 0 {
attempts = 3
}
delay := g.RetryDelay
if delay == 0 {
delay = 4 * time.Second
}

var respBody []byte
var status int

err := retry.Do(
func() error {
b, s, e := g.postOnceWithBearer(body, bearerToken)
respBody, status = b, s
if e != nil {
return fmt.Errorf("gateway request failed: %w", e)
}
if s != http.StatusOK {
return retry.Unrecoverable(fmt.Errorf("gateway returned non-200: status_code=%d, body=%s", s, string(respBody)))
}
return nil
},
retry.Attempts(uint(attempts)),
retry.Delay(delay),
retry.LastErrorOnly(true),
retry.OnRetry(func(n uint, err error) {
ui.Dim(fmt.Sprintf("Retrying vault gateway request... (attempt %d/%d): %v", n+1, attempts, err))
}),
)

if err != nil {
return respBody, status, fmt.Errorf("gateway POST failed: %w", err)
}
return respBody, status, nil
}

func (g *HTTPClient) postOnce(body []byte) ([]byte, int, error) {
req, err := http.NewRequest("POST", g.URL, bytes.NewBuffer(body))
if err != nil {
Expand All @@ -98,3 +143,29 @@ func (g *HTTPClient) postOnce(body []byte) ([]byte, int, error) {
}
return b, resp.StatusCode, nil
}

func (g *HTTPClient) postOnceWithBearer(body []byte, bearerToken string) ([]byte, int, error) {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

req, err := http.NewRequest("POST", g.URL, bytes.NewBuffer(body))
if err != nil {
return nil, 0, fmt.Errorf("create HTTP request: %w", err)
}
req.Header.Set("Content-Type", "application/jsonrpc")
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+bearerToken)

if g.Client == nil {
return nil, 0, fmt.Errorf("HTTP client is not initialized")
}

resp, err := g.Client.Do(req) // #nosec G704 -- URL is from trusted CLI configuration
if err != nil {
return nil, 0, fmt.Errorf("HTTP request to gateway failed: %w", err)
}
defer resp.Body.Close()

b, rerr := io.ReadAll(resp.Body)
if rerr != nil {
return nil, resp.StatusCode, fmt.Errorf("read response body: %w", rerr)
}
return b, resp.StatusCode, nil
}
72 changes: 72 additions & 0 deletions cmd/secrets/common/gateway_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import (
"errors"
"io"
"net/http"
"net/http/httptest"
"testing"

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

// errReadCloser simulates a failure while reading the body.
Expand Down Expand Up @@ -187,3 +189,73 @@ func TestPostToGateway(t *testing.T) {
assert.Equal(t, 2, rt.Calls) // retried and succeeded
})
}

func TestPostWithBearer(t *testing.T) {
var sawAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sawAuth = r.Header.Get("Authorization")
assert.Equal(t, "application/jsonrpc", r.Header.Get("Content-Type"))
_, _ = io.Copy(io.Discard, r.Body)
_, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":"x","result":{}}`))
}))
t.Cleanup(srv.Close)

g := &HTTPClient{
URL: srv.URL,
Client: srv.Client(),
RetryAttempts: 1,
RetryDelay: 0,
}
body := []byte(`{"jsonrpc":"2.0","id":"1","method":"test","params":{}}`)
resp, status, err := g.PostWithBearer(body, "my-jwt")
require.NoError(t, err)
assert.Equal(t, 200, status)
assert.Contains(t, string(resp), "jsonrpc")
assert.Equal(t, "Bearer my-jwt", sawAuth)
}

func TestPostWithBearer_EmptyToken(t *testing.T) {
g := &HTTPClient{URL: "http://example.com", Client: http.DefaultClient}
_, _, err := g.PostWithBearer([]byte(`{}`), " ")
assert.Error(t, err)
assert.Contains(t, err.Error(), "empty bearer token")
}

func TestPostWithBearer_Non200(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
}))
t.Cleanup(srv.Close)

g := &HTTPClient{
URL: srv.URL,
Client: srv.Client(),
RetryAttempts: 3,
RetryDelay: 0,
}
_, status, err := g.PostWithBearer([]byte(`{}`), "tok")
assert.Error(t, err)
assert.Equal(t, http.StatusUnauthorized, status)
assert.Contains(t, err.Error(), "non-200")
}

func TestPostWithBearer_TransportErrorThenSuccess(t *testing.T) {
body := `{"ok":true}`
rt := &SeqRoundTripper{
Seq: []RTResponse{
{Err: errors.New("connection reset")},
{Response: makeResp(200, body)},
},
}
g := &HTTPClient{
URL: "https://unit-test.gw",
Client: &http.Client{Transport: rt},
RetryAttempts: 3,
RetryDelay: 0,
}
respBytes, status, err := g.PostWithBearer([]byte(`{}`), "jwt")
assert.NoError(t, err)
assert.Equal(t, 200, status)
assert.Equal(t, body, string(respBytes))
assert.Equal(t, 2, rt.Calls)
}
4 changes: 4 additions & 0 deletions cmd/secrets/common/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ func (m *mockGatewayClient) Post(b []byte) ([]byte, int, error) {
return m.post(b)
}

func (m *mockGatewayClient) PostWithBearer(b []byte, _ string) ([]byte, int, error) {
return m.post(b)
}

// It represents a hex-encoded tdh2easy.PublicKey blob.
const vaultPublicKeyHex = "7b2247726f7570223a2250323536222c22475f626172223a22424d704759487a2b33333432596436582f2b6d4971396d5468556c6d2f317355716b51783333343564303373472b2f2f307257494d39795a70454b44566c6c2b616f36586c513743366546452b665472356568785a4f343d222c2248223a22424257546f7638394b546b41505a7566474454504e35626f456d6453305368697975696e3847336e58517774454931536333394453314b41306a595a6576546155476775444d694431746e6e4d686575373177574b57593d222c22484172726179223a5b22424937726649364c646f7654413948676a684b5955516a4744456a5a66374f30774378466c432f2f384e394733464c796247436d6e54734236632b50324c34596a39477548555a4936386d54342b4e77786f794b6261513d222c22424736634369395574317a65433753786b4c442b6247354751505473717463324a7a544b4c726b784d496e4c36484e7658376541324b6167423243447a4b6a6f76783570414c6a74523734537a6c7146543366746662513d222c224245576f7147546d6b47314c31565a53655874345147446a684d4d2b656e7a6b426b7842782b484f72386e39336b51543963594938486f513630356a65504a732f53575866355a714534564e676b4f672f643530395a6b3d222c22424a31552b6e5344783269567a654177475948624e715242564869626b74466b624f4762376158562f3946744c6876314b4250416c3272696e73714171754459504e2f54667870725a6e655259594a2b2f453162536a673d222c224243675a623770424d777732337138577767736e322b6c4d665259343561347576445345715a7559614e2f356e64744970355a492f4a6f454d372b36304a6338735978682b535365364645683052364f57666855706d453d222c2242465a5942524a336d6647695644312b4f4b4e4f374c54355a6f6574515442624a6b464152757143743268492f52757832756b7166794c6c364d71566e55613557336e49726e71506132566d5345755758546d39456f733d222c22424f716b662f356232636c4d314a78615831446d6a76494c4437334f6734566b42732f4b686b6e4d6867435772552f30574a36734e514a6b425462686b4a5535576b48506342626d45786c6362706a49743349494632303d225d7d"

Expand Down
2 changes: 1 addition & 1 deletion cmd/secrets/delete/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ func Execute(h *common.Handler, inputs DeleteSecretsInputs, duration time.Durati

if common.IsBrowserFlow(secretsAuth) {
ui.Dim("Using your account to authorize vault access for this delete request...")
return h.ExecuteBrowserVaultAuthorization(context.Background(), vaulttypes.MethodSecretsDelete, digest)
return h.ExecuteBrowserVaultAuthorization(context.Background(), vaulttypes.MethodSecretsDelete, digest, requestBody)
}

gatewayPost := func() error {
Expand Down
Loading
Loading