diff --git a/.changeset/chatty-parents-pick.md b/.changeset/chatty-parents-pick.md new file mode 100644 index 00000000..2b0947b3 --- /dev/null +++ b/.changeset/chatty-parents-pick.md @@ -0,0 +1,5 @@ +--- +"cre-cli": patch +--- + +Submit oauth secrets to vault DON diff --git a/cmd/secrets/common/browser_flow.go b/cmd/secrets/common/browser_flow.go index 4cd5ba55..06cfc4b2 100644 --- a/cmd/secrets/common/browser_flow.go +++ b/cmd/secrets/common/browser_flow.go @@ -3,6 +3,7 @@ package common import ( "context" "encoding/hex" + "encoding/json" "fmt" "net/http" rt "runtime" @@ -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 { @@ -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: @@ -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]{ @@ -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 { @@ -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) } diff --git a/cmd/secrets/common/browser_flow_test.go b/cmd/secrets/common/browser_flow_test.go index 66e147a9..7d80447e 100644 --- a/cmd/secrets/common/browser_flow_test.go +++ b/cmd/secrets/common/browser_flow_test.go @@ -1,8 +1,13 @@ package common import ( + "bytes" "crypto/sha256" "encoding/base64" + "io" + "net/http" + "os" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -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") +} diff --git a/cmd/secrets/common/gateway.go b/cmd/secrets/common/gateway.go index 22c1e434..4043d61f 100644 --- a/cmd/secrets/common/gateway.go +++ b/cmd/secrets/common/gateway.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "net/http" + "strings" "time" "github.com/avast/retry-go/v4" @@ -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 { @@ -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 { @@ -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) { + 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 +} diff --git a/cmd/secrets/common/gateway_test.go b/cmd/secrets/common/gateway_test.go index 8194565d..9a177676 100644 --- a/cmd/secrets/common/gateway_test.go +++ b/cmd/secrets/common/gateway_test.go @@ -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. @@ -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) +} diff --git a/cmd/secrets/common/handler_test.go b/cmd/secrets/common/handler_test.go index 391fd5db..bbc05c49 100644 --- a/cmd/secrets/common/handler_test.go +++ b/cmd/secrets/common/handler_test.go @@ -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" diff --git a/cmd/secrets/delete/delete.go b/cmd/secrets/delete/delete.go index cb32854f..25b753c8 100644 --- a/cmd/secrets/delete/delete.go +++ b/cmd/secrets/delete/delete.go @@ -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 { diff --git a/cmd/secrets/list/list.go b/cmd/secrets/list/list.go index be2a5c12..2967142d 100644 --- a/cmd/secrets/list/list.go +++ b/cmd/secrets/list/list.go @@ -131,7 +131,7 @@ func Execute(h *common.Handler, namespace string, duration time.Duration, secret if common.IsBrowserFlow(secretsAuth) { ui.Dim("Using your account to authorize vault access for this list request...") - return h.ExecuteBrowserVaultAuthorization(context.Background(), vaulttypes.MethodSecretsList, digest) + return h.ExecuteBrowserVaultAuthorization(context.Background(), vaulttypes.MethodSecretsList, digest, body) } ownerAddr := ethcommon.HexToAddress(owner)